MCP server for tangled

Compare changes

Choose any two refs to compare.

+1 -1
.github/workflows/publish-mcp.yml
··· 36 36 37 37 - name: install mcp publisher 38 38 run: | 39 - curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.2.3/mcp-publisher_1.2.3_linux_amd64.tar.gz" | tar xz 39 + curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.3/mcp-publisher_linux_amd64.tar.gz" | tar xz 40 40 sudo mv mcp-publisher /usr/local/bin/ 41 41 42 42 - name: login to mcp registry (github oidc)
+4 -1
README.md
··· 102 102 - `create_repo_issue(repo, title, body, labels)` - create an issue with optional labels 103 103 - `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels 104 104 - `delete_repo_issue(repo, issue_id)` - delete an issue 105 - - `list_repo_issues(repo, limit, cursor)` - list issues for a repository 105 + - `list_repo_issues(repo, limit)` - list issues for a repository 106 106 - `list_repo_labels(repo)` - list available labels for a repository 107 + 108 + ### pull requests 109 + - `list_repo_pulls(repo, limit)` - list PRs targeting a repository (only shows PRs you created) 107 110 108 111 ## development 109 112
+3 -3
server.json
··· 1 1 { 2 - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", 2 + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", 3 3 "name": "io.github.zzstoatzz/tangled-mcp", 4 4 "title": "Tangled MCP", 5 5 "description": "MCP server for Tangled git platform. Manage repositories, branches, and issues on tangled.org.", 6 - "version": "0.0.10", 6 + "version": "0.0.14", 7 7 "packages": [ 8 8 { 9 9 "registryType": "pypi", 10 10 "identifier": "tangled-mcp", 11 - "version": "0.0.10", 11 + "version": "0.0.14", 12 12 "transport": { 13 13 "type": "stdio" 14 14 }
+2
src/tangled_mcp/_tangled/__init__.py
··· 13 13 list_repo_labels, 14 14 update_issue, 15 15 ) 16 + from tangled_mcp._tangled._pulls import list_repo_pulls 16 17 17 18 __all__ = [ 18 19 "_get_authenticated_client", ··· 23 24 "delete_issue", 24 25 "list_repo_issues", 25 26 "list_repo_labels", 27 + "list_repo_pulls", 26 28 "resolve_repo_identifier", 27 29 ]
+25 -7
src/tangled_mcp/_tangled/_client.py
··· 8 8 from tangled_mcp.settings import TANGLED_DID, settings 9 9 10 10 11 + def _extract_error_message(e: Exception) -> str: 12 + """extract a clean, concise error message from an exception""" 13 + # handle atproto RequestErrorBase (BadRequestError, UnauthorizedError, etc.) 14 + # structure: e.response.content.message 15 + if hasattr(e, "response") and e.response: 16 + content = getattr(e.response, "content", None) 17 + if content and hasattr(content, "message"): 18 + return content.message 19 + # handle httpx errors 20 + if hasattr(e, "response") and hasattr(e.response, "text"): 21 + return e.response.text[:200] 22 + # fallback to string but limit length 23 + msg = str(e) 24 + if len(msg) > 200: 25 + return msg[:200] + "..." 26 + return msg 27 + 28 + 11 29 def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]: 12 30 """resolve owner/repo format to (knot, did/repo) for tangled XRPC 13 31 ··· 44 62 ) 45 63 owner_did = response.did 46 64 except Exception as e: 47 - raise ValueError(f"failed to resolve handle '{owner}': {e}") from e 65 + # extract clean error message 66 + msg = _extract_error_message(e) 67 + raise ValueError(f"failed to resolve handle '{owner}': {msg}") from e 48 68 49 69 # query owner's repo collection to find repo and get knot 50 70 try: ··· 56 76 ) 57 77 ) 58 78 except Exception as e: 59 - raise ValueError(f"failed to list repos for '{owner}': {e}") from e 79 + msg = _extract_error_message(e) 80 + raise ValueError(f"failed to list repos for '{owner}': {msg}") from e 60 81 61 82 # find repo with matching name and extract knot 62 83 for record in records.records: ··· 86 107 try: 87 108 client.login(settings.tangled_handle, settings.tangled_password) 88 109 except Exception as e: 89 - raise RuntimeError( 90 - f"failed to authenticate with handle '{settings.tangled_handle}'. " 91 - f"verify TANGLED_HANDLE and TANGLED_PASSWORD are correct. " 92 - f"error: {e}" 93 - ) from e 110 + msg = _extract_error_message(e) 111 + raise RuntimeError(f"auth failed for '{settings.tangled_handle}': {msg}") from e 94 112 95 113 return client 96 114
+92
src/tangled_mcp/_tangled/_pulls.py
··· 1 + """pull request operations for tangled""" 2 + 3 + from typing import Any 4 + 5 + from atproto import models 6 + 7 + from tangled_mcp._tangled._client import _get_authenticated_client 8 + 9 + 10 + def list_repo_pulls(repo_id: str, limit: int = 50) -> dict[str, Any]: 11 + """list pull requests created by the authenticated user for a repository 12 + 13 + note: this only returns PRs that the authenticated user created. 14 + tangled stores PRs in the creator's repo, so we can only see our own PRs. 15 + 16 + Args: 17 + repo_id: repository identifier in "did/repo" format 18 + limit: maximum number of pulls to return 19 + 20 + Returns: 21 + dict containing pulls list 22 + """ 23 + client = _get_authenticated_client() 24 + 25 + if not client.me: 26 + raise RuntimeError("client not authenticated") 27 + 28 + # parse repo_id to get owner_did and repo_name 29 + if "/" not in repo_id: 30 + raise ValueError(f"invalid repo_id format: {repo_id}") 31 + 32 + owner_did, repo_name = repo_id.split("/", 1) 33 + 34 + # get the repo AT-URI by querying the repo collection 35 + records = client.com.atproto.repo.list_records( 36 + models.ComAtprotoRepoListRecords.Params( 37 + repo=owner_did, 38 + collection="sh.tangled.repo", 39 + limit=100, 40 + ) 41 + ) 42 + 43 + repo_at_uri = None 44 + for record in records.records: 45 + if (name := getattr(record.value, "name", None)) is not None and name == repo_name: 46 + repo_at_uri = record.uri 47 + break 48 + 49 + if not repo_at_uri: 50 + raise ValueError(f"repo not found: {repo_id}") 51 + 52 + # list pull records from the authenticated user's collection 53 + response = client.com.atproto.repo.list_records( 54 + models.ComAtprotoRepoListRecords.Params( 55 + repo=client.me.did, 56 + collection="sh.tangled.repo.pull", 57 + limit=limit, 58 + ) 59 + ) 60 + 61 + # filter pulls by target repo and convert to dict format 62 + pulls = [] 63 + for record in response.records: 64 + value = record.value 65 + target = getattr(value, "target", None) 66 + if not target: 67 + continue 68 + 69 + target_repo = getattr(target, "repo", None) 70 + if target_repo != repo_at_uri: 71 + continue 72 + 73 + source = getattr(value, "source", {}) 74 + pulls.append( 75 + { 76 + "uri": record.uri, 77 + "cid": record.cid, 78 + "title": getattr(value, "title", ""), 79 + "source": { 80 + "sha": getattr(source, "sha", ""), 81 + "branch": getattr(source, "branch", ""), 82 + "repo": getattr(source, "repo", None), 83 + }, 84 + "target": { 85 + "repo": target_repo, 86 + "branch": getattr(target, "branch", ""), 87 + }, 88 + "createdAt": getattr(value, "createdAt", ""), 89 + } 90 + ) 91 + 92 + return {"pulls": pulls}
+36 -3
src/tangled_mcp/server.py
··· 11 11 DeleteIssueResult, 12 12 ListBranchesResult, 13 13 ListIssuesResult, 14 + ListPullsResult, 14 15 UpdateIssueResult, 15 16 ) 16 17 ··· 104 105 # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 105 106 response = _tangled.create_issue(repo_id, title, body, labels) 106 107 107 - return CreateIssueResult(repo=repo, issue_id=response["issueId"]) 108 + return CreateIssueResult(repo=repo, id=response["issueId"]) 108 109 109 110 110 111 @tangled_mcp.tool ··· 143 144 # update_issue doesn't need knot (uses atproto putRecord, not XRPC) 144 145 _tangled.update_issue(repo_id, issue_id, title, body, labels) 145 146 146 - return UpdateIssueResult(repo=repo, issue_id=issue_id) 147 + return UpdateIssueResult(repo=repo, id=issue_id) 147 148 148 149 149 150 @tangled_mcp.tool ··· 172 173 # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) 173 174 _tangled.delete_issue(repo_id, issue_id) 174 175 175 - return DeleteIssueResult(issue_id=issue_id) 176 + return DeleteIssueResult(id=issue_id) 176 177 177 178 178 179 @tangled_mcp.tool ··· 225 226 _, repo_id = _tangled.resolve_repo_identifier(repo) 226 227 # list_repo_labels doesn't need knot (queries atproto records, not XRPC) 227 228 return _tangled.list_repo_labels(repo_id) 229 + 230 + 231 + @tangled_mcp.tool 232 + def list_repo_pulls( 233 + repo: Annotated[ 234 + str, 235 + Field( 236 + description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 237 + ), 238 + ], 239 + limit: Annotated[ 240 + int, Field(ge=1, le=100, description="maximum number of pulls to return") 241 + ] = 20, 242 + ) -> ListPullsResult: 243 + """list pull requests created by the authenticated user for a repository 244 + 245 + note: only returns PRs that the authenticated user created (tangled stores 246 + PRs in the creator's repo, so we can only see our own PRs). 247 + 248 + Args: 249 + repo: repository identifier in 'owner/repo' format 250 + limit: maximum number of pulls to return (1-100) 251 + 252 + Returns: 253 + ListPullsResult with list of pull requests 254 + """ 255 + # resolve owner/repo to (knot, did/repo) 256 + _, repo_id = _tangled.resolve_repo_identifier(repo) 257 + # list_repo_pulls doesn't need knot (queries atproto records, not XRPC) 258 + response = _tangled.list_repo_pulls(repo_id, limit) 259 + 260 + return ListPullsResult.from_api_response(response["pulls"])
+5
src/tangled_mcp/types/__init__.py
··· 9 9 ListIssuesResult, 10 10 UpdateIssueResult, 11 11 ) 12 + from tangled_mcp.types._pulls import ListPullsResult, PullInfo, PullSource, PullTarget 12 13 13 14 __all__ = [ 14 15 "BranchInfo", ··· 17 18 "IssueInfo", 18 19 "ListBranchesResult", 19 20 "ListIssuesResult", 21 + "ListPullsResult", 22 + "PullInfo", 23 + "PullSource", 24 + "PullTarget", 20 25 "RepoIdentifier", 21 26 "UpdateIssueResult", 22 27 ]
+14 -9
src/tangled_mcp/types/_issues.py
··· 18 18 19 19 uri: str 20 20 cid: str 21 - issue_id: int = Field(alias="issueId") 21 + id: int = Field(alias="issueId") 22 22 title: str 23 23 body: str | None = None 24 24 created_at: str = Field(alias="createdAt") ··· 28 28 class CreateIssueResult(BaseModel): 29 29 """result of creating an issue""" 30 30 31 - repo: RepoIdentifier 32 - issue_id: int 31 + repo: RepoIdentifier = Field(exclude=True) 32 + id: int 33 33 34 34 @computed_field 35 35 @property 36 36 def url(self) -> str: 37 37 """construct clickable tangled.org URL""" 38 - return _tangled_issue_url(self.repo, self.issue_id) 38 + return _tangled_issue_url(self.repo, self.id) 39 39 40 40 41 41 class UpdateIssueResult(BaseModel): 42 42 """result of updating an issue""" 43 43 44 - repo: RepoIdentifier 45 - issue_id: int 44 + repo: RepoIdentifier = Field(exclude=True) 45 + id: int 46 46 47 47 @computed_field 48 48 @property 49 49 def url(self) -> str: 50 50 """construct clickable tangled.org URL""" 51 - return _tangled_issue_url(self.repo, self.issue_id) 51 + return _tangled_issue_url(self.repo, self.id) 52 52 53 53 54 54 class DeleteIssueResult(BaseModel): 55 55 """result of deleting an issue""" 56 56 57 - issue_id: int 57 + id: int 58 58 59 59 60 60 class ListIssuesResult(BaseModel): ··· 85 85 Returns: 86 86 ListIssuesResult with parsed issues 87 87 """ 88 - issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] 88 + issues = [] 89 + for issue_data in response.get("issues", []): 90 + # skip malformed issues (e.g., missing issueId) 91 + if issue_data.get("issueId") is None: 92 + continue 93 + issues.append(IssueInfo(**issue_data)) 89 94 return cls(issues=issues)
+70
src/tangled_mcp/types/_pulls.py
··· 1 + """pull request types""" 2 + 3 + from typing import Any 4 + 5 + from pydantic import BaseModel, Field 6 + 7 + 8 + class PullSource(BaseModel): 9 + """source branch info for a pull request""" 10 + 11 + sha: str 12 + branch: str 13 + repo: str | None = None # AT-URI of source repo (for cross-repo PRs) 14 + 15 + 16 + class PullTarget(BaseModel): 17 + """target branch info for a pull request""" 18 + 19 + repo: str # AT-URI of target repo 20 + branch: str 21 + 22 + 23 + class PullInfo(BaseModel): 24 + """pull request information""" 25 + 26 + uri: str 27 + cid: str 28 + title: str 29 + source: PullSource 30 + target: PullTarget 31 + created_at: str = Field(alias="createdAt") 32 + 33 + 34 + class ListPullsResult(BaseModel): 35 + """result of listing pull requests""" 36 + 37 + pulls: list[PullInfo] 38 + 39 + @classmethod 40 + def from_api_response(cls, pulls_data: list[dict[str, Any]]) -> "ListPullsResult": 41 + """construct from pre-filtered pull data 42 + 43 + Args: 44 + pulls_data: list of pull dicts already filtered by target repo 45 + 46 + Returns: 47 + ListPullsResult with parsed pulls 48 + """ 49 + pulls = [] 50 + for pull in pulls_data: 51 + source = pull.get("source", {}) 52 + target = pull.get("target", {}) 53 + pulls.append( 54 + PullInfo( 55 + uri=pull["uri"], 56 + cid=pull["cid"], 57 + title=pull.get("title", ""), 58 + source=PullSource( 59 + sha=source.get("sha", ""), 60 + branch=source.get("branch", ""), 61 + repo=source.get("repo"), 62 + ), 63 + target=PullTarget( 64 + repo=target.get("repo", ""), 65 + branch=target.get("branch", ""), 66 + ), 67 + createdAt=pull.get("createdAt", ""), 68 + ) 69 + ) 70 + return cls(pulls=pulls)
+43
tests/test_resolver.py
··· 2 2 3 3 import pytest 4 4 5 + from tangled_mcp._tangled._client import _extract_error_message 6 + 7 + 8 + class TestExtractErrorMessage: 9 + """test error message extraction from various exception types""" 10 + 11 + def test_extracts_from_atproto_response(self): 12 + """extracts message from atproto RequestErrorBase structure""" 13 + 14 + class MockContent: 15 + message = "handle must be a valid handle" 16 + 17 + class MockResponse: 18 + content = MockContent() 19 + 20 + class MockException(Exception): 21 + response = MockResponse() 22 + 23 + msg = _extract_error_message(MockException()) 24 + assert msg == "handle must be a valid handle" 25 + 26 + def test_truncates_long_messages(self): 27 + """truncates messages longer than 200 chars""" 28 + long_msg = "x" * 300 29 + 30 + class MockException(Exception): 31 + pass 32 + 33 + e = MockException(long_msg) 34 + msg = _extract_error_message(e) 35 + assert len(msg) == 203 # 200 + "..." 36 + assert msg.endswith("...") 37 + 38 + def test_handles_none_response(self): 39 + """handles exception with None response""" 40 + 41 + class MockException(Exception): 42 + response = None 43 + 44 + e = MockException("fallback message") 45 + msg = _extract_error_message(e) 46 + assert msg == "fallback message" 47 + 5 48 6 49 class TestRepoIdentifierParsing: 7 50 """test repository identifier format validation"""
+2 -1
tests/test_server.py
··· 22 22 async with Client(tangled_mcp) as client: 23 23 tools = await client.list_tools() 24 24 25 - assert len(tools) == 6 25 + assert len(tools) == 7 26 26 27 27 tool_names = {tool.name for tool in tools} 28 28 assert "list_repo_branches" in tool_names ··· 31 31 assert "delete_repo_issue" in tool_names 32 32 assert "list_repo_issues" in tool_names 33 33 assert "list_repo_labels" in tool_names 34 + assert "list_repo_pulls" in tool_names 34 35 35 36 async def test_list_repo_branches_tool_schema(self): 36 37 """test list_repo_branches tool has correct schema"""
+14 -6
tests/test_types.py
··· 15 15 16 16 def test_strips_at_prefix(self): 17 17 """@ prefix is stripped during validation""" 18 - result = CreateIssueResult(repo="@owner/repo", issue_id=1) 18 + result = CreateIssueResult(repo="@owner/repo", id=1) 19 19 assert result.repo == "owner/repo" 20 20 21 21 def test_accepts_without_at_prefix(self): 22 22 """repo identifier without @ works""" 23 - result = CreateIssueResult(repo="owner/repo", issue_id=1) 23 + result = CreateIssueResult(repo="owner/repo", id=1) 24 24 assert result.repo == "owner/repo" 25 25 26 26 def test_rejects_invalid_format(self): 27 27 """repo identifier without slash is rejected""" 28 28 with pytest.raises(ValidationError, match="invalid repo format"): 29 - CreateIssueResult(repo="invalid", issue_id=1) 29 + CreateIssueResult(repo="invalid", id=1) 30 30 31 31 32 32 class TestIssueResultURLs: ··· 34 34 35 35 def test_create_issue_url(self): 36 36 """create result generates correct tangled.org URL""" 37 - result = CreateIssueResult(repo="owner/repo", issue_id=42) 37 + result = CreateIssueResult(repo="owner/repo", id=42) 38 38 assert result.url == "https://tangled.org/@owner/repo/issues/42" 39 39 40 40 def test_update_issue_url(self): 41 41 """update result generates correct tangled.org URL""" 42 - result = UpdateIssueResult(repo="owner/repo", issue_id=42) 42 + result = UpdateIssueResult(repo="owner/repo", id=42) 43 43 assert result.url == "https://tangled.org/@owner/repo/issues/42" 44 44 45 45 def test_url_handles_at_prefix_input(self): 46 46 """URL is correct even when input has @ prefix""" 47 - result = CreateIssueResult(repo="@owner/repo", issue_id=42) 47 + result = CreateIssueResult(repo="@owner/repo", id=42) 48 48 assert result.url == "https://tangled.org/@owner/repo/issues/42" 49 + 50 + def test_repo_excluded_from_serialization(self): 51 + """repo field is excluded from JSON output""" 52 + result = CreateIssueResult(repo="owner/repo", id=42) 53 + data = result.model_dump() 54 + assert "repo" not in data 55 + assert data["id"] == 42 56 + assert "url" in data 49 57 50 58 51 59 class TestListBranchesFromAPIResponse: