MCP server for tangled

refactor types and improve issue operations

## types architecture
- refactor types.py → types/ directory structure
- _common.py: RepoIdentifier validation with Annotated types
- _branches.py: branch types + ListBranchesResult.from_api_response()
- _issues.py: issue types + ListIssuesResult.from_api_response()
- __init__.py: public API
- move parsing logic into types via class method constructors
- parsing logic out of tool functions (DRY, separation of concerns)

## issue operations improvements
- return clickable URLs instead of AT Protocol URIs/CIDs
- CreateIssueResult/UpdateIssueResult: {url, issue_id}
- DeleteIssueResult: {issue_id}
- URL generation via @computed_field in types
- RepoIdentifier validator strips @ prefix, normalizes format
- all operations return proper Pydantic models (no dict[str, Any])

## better auth error messages
- _get_authenticated_client() now provides actionable errors
- tells users to verify TANGLED_HANDLE and TANGLED_PASSWORD

## documentation
- wrap MCP client installation in <details> for cleaner README
- add NEXT_STEPS.md documenting critical issues found:
- silent label validation failures (labels must fail loudly)
- missing label data in list_repo_issues
- pydantic field warning

## testing
- add test_types.py with 9 tests covering public API
- validates: RepoIdentifier normalization, URL generation, API parsing
- all 17 tests passing

no breaking changes to public API, all existing functionality preserved

+137
NEXT_STEPS.md
··· 1 + # next steps 2 + 3 + ## critical fixes 4 + 5 + ### 1. label validation must fail loudly 6 + 7 + **problem:** when users specify labels that don't exist in the repo's subscribed label definitions, they're silently ignored. no error, no warning, just nothing happens. 8 + 9 + **current behavior:** 10 + ```python 11 + create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"]) 12 + # -> creates issue with NO labels, returns success 13 + ``` 14 + 15 + **what should happen:** 16 + ```python 17 + create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"]) 18 + # -> raises ValueError: 19 + # "invalid labels: ['demo', 'nonexistent'] 20 + # available labels for this repo: ['wontfix', 'duplicate', 'good-first-issue', ...]" 21 + ``` 22 + 23 + **fix locations:** 24 + - `src/tangled_mcp/_tangled/_issues.py:_apply_labels()` - validate before applying 25 + - add `validate_labels()` helper that checks against repo's subscribed labels 26 + - fail fast with actionable error message listing available labels 27 + 28 + ### 2. list_repo_issues should include label information 29 + 30 + **problem:** `list_repo_issues` returns issues but doesn't include their labels. labels are stored separately in `sh.tangled.label.op` records and need to be fetched and correlated. 31 + 32 + **impact:** users can't see what labels an issue has without manually querying label ops or checking the UI. 33 + 34 + **fix:** 35 + - add `labels: list[str]` field to `IssueInfo` model 36 + - in `list_repo_issues`, fetch label ops and correlate with issues 37 + - return label names (not URIs) for better UX 38 + 39 + ### 3. fix pydantic field warning 40 + 41 + **warning:** 42 + ``` 43 + UnsupportedFieldAttributeWarning: The 'default' attribute with value None was provided 44 + to the `Field()` function, which has no effect in the context it was used. 45 + ``` 46 + 47 + **likely cause:** somewhere we're using `Field(default=None)` in an `Annotated` type or union context where it doesn't make sense. 48 + 49 + **fix:** audit all `Field()` uses and remove invalid `default=None` declarations. 50 + 51 + ## enhancements 52 + 53 + ### 4. better error messages for repo resolution failures 54 + 55 + when a repo doesn't exist or handle can't be resolved, give users clear next steps: 56 + - is the repo name spelled correctly? 57 + - does the repo exist on tangled.org? 58 + - do you have access to it? 59 + 60 + ### 5. add label listing tool 61 + 62 + users need to know what labels are available for a repo before they can use them. 63 + 64 + **new tool:** 65 + ```python 66 + list_repo_labels(repo: str) -> list[str] 67 + # returns: ["wontfix", "duplicate", "good-first-issue", ...] 68 + ``` 69 + 70 + ### 6. pagination cursor handling 71 + 72 + currently returning raw cursor strings. consider: 73 + - documenting cursor format 74 + - providing helper for "has more pages" checking 75 + - clear examples in docstrings 76 + 77 + ## completed improvements (this session) 78 + 79 + ### ✅ types architecture refactored 80 + - moved from single `types.py` to `types/` directory 81 + - separated concerns: `_common.py`, `_branches.py`, `_issues.py` 82 + - public API in `__init__.py` 83 + - parsing logic moved into types via `.from_api_response()` class methods 84 + 85 + ### ✅ proper validation with annotated types 86 + - `RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]` 87 + - strips `@` prefix automatically 88 + - validates format before processing 89 + 90 + ### ✅ clickable URLs instead of AT Protocol internals 91 + - issue operations return `https://tangled.org/@owner/repo/issues/N` 92 + - removed useless `uri` and `cid` from user-facing responses 93 + - URL generation encapsulated in types via `@computed_field` 94 + 95 + ### ✅ proper typing everywhere 96 + - no more `dict[str, Any]` return types 97 + - pydantic models for all results 98 + - type safety throughout 99 + 100 + ### ✅ minimal test coverage 101 + - 17 tests covering public contracts 102 + - no implementation details tested 103 + - validates key behaviors: URL generation, validation, parsing 104 + 105 + ### ✅ demo scripts 106 + - full lifecycle demo 107 + - URL format handling demo 108 + - branch listing demo 109 + - label manipulation demo (revealed silent failure issue) 110 + 111 + ### ✅ documentation improvements 112 + - MCP client installation instructions in collapsible details 113 + - clear usage examples for multiple clients 114 + 115 + ## technical debt 116 + 117 + ### remove unused types 118 + - `RepoInfo`, `PullInfo`, `CreateRepoResult`, `GenericResult` - not used anywhere 119 + - clean up or remove from public API 120 + 121 + ### consolidate URL generation logic 122 + - `_tangled_issue_url()` helper was created to DRY the URL generation 123 + - good pattern, consider extending to other URL types if needed 124 + 125 + ### consider lazy evaluation for expensive validations 126 + - repo resolution happens on every tool call 127 + - could cache repo metadata (knot, did) for duration of connection 128 + - tradeoff: freshness vs performance 129 + 130 + ## priorities 131 + 132 + 1. **critical:** fix label validation (fails silently) 133 + 2. **high:** add labels to list_repo_issues output 134 + 3. **medium:** add list_repo_labels tool 135 + 4. **medium:** fix pydantic warning 136 + 5. **low:** better error messages 137 + 6. **low:** clean up unused types
+8 -5
README.md
··· 28 28 29 29 ## usage 30 30 31 - ### using with MCP clients 31 + <details> 32 + <summary>MCP client installation instructions</summary> 32 33 33 - #### claude code 34 + ### claude code 34 35 35 36 ```bash 36 37 # basic setup ··· 43 44 -- uvx tangled-mcp 44 45 ``` 45 46 46 - #### cursor 47 + ### cursor 47 48 48 49 add to your cursor settings (`~/.cursor/mcp.json` or `.cursor/mcp.json`): 49 50 ··· 62 63 } 63 64 ``` 64 65 65 - #### codex cli 66 + ### codex cli 66 67 67 68 ```bash 68 69 codex mcp add tangled \ ··· 71 72 -- uvx tangled-mcp 72 73 ``` 73 74 74 - #### other clients 75 + ### other clients 75 76 76 77 for clients that support MCP server configuration, use: 77 78 - **command**: `uvx` 78 79 - **args**: `["tangled-mcp"]` 79 80 - **environment variables**: `TANGLED_HANDLE`, `TANGLED_PASSWORD`, and optionally `TANGLED_PDS_URL` 81 + 82 + </details> 80 83 81 84 ### development usage 82 85
+29 -39
src/tangled_mcp/server.py
··· 1 1 """tangled MCP server - provides tools and resources for tangled git platform""" 2 2 3 - from typing import Annotated, Any 3 + from typing import Annotated 4 4 5 5 from fastmcp import FastMCP 6 6 from pydantic import Field 7 7 8 8 from tangled_mcp import _tangled 9 - from tangled_mcp.types import BranchInfo, ListBranchesResult 9 + from tangled_mcp.types import ( 10 + CreateIssueResult, 11 + DeleteIssueResult, 12 + ListBranchesResult, 13 + ListIssuesResult, 14 + UpdateIssueResult, 15 + ) 10 16 11 17 tangled_mcp = FastMCP("tangled MCP server") 12 18 ··· 63 69 knot, repo_id = _tangled.resolve_repo_identifier(repo) 64 70 response = _tangled.list_branches(knot, repo_id, limit, cursor) 65 71 66 - # parse response into BranchInfo objects 67 - branches = [] 68 - if "branches" in response: 69 - for branch_data in response["branches"]: 70 - ref = branch_data.get("reference", {}) 71 - branches.append( 72 - BranchInfo( 73 - name=ref.get("name", ""), 74 - sha=ref.get("hash", ""), 75 - ) 76 - ) 77 - 78 - return ListBranchesResult(branches=branches, cursor=response.get("cursor")) 72 + return ListBranchesResult.from_api_response(response) 79 73 80 74 81 75 @tangled_mcp.tool ··· 95 89 "to apply to the issue" 96 90 ), 97 91 ] = None, 98 - ) -> dict[str, str | int]: 92 + ) -> CreateIssueResult: 99 93 """create an issue on a repository 100 94 101 95 Args: ··· 105 99 labels: optional list of label names to apply 106 100 107 101 Returns: 108 - dict with uri, cid, and issueId of created issue 102 + CreateIssueResult with url (clickable link) and issue_id 109 103 """ 110 104 # resolve owner/repo to (knot, did/repo) 111 105 knot, repo_id = _tangled.resolve_repo_identifier(repo) 112 106 # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 113 107 response = _tangled.create_issue(repo_id, title, body, labels) 114 - return { 115 - "uri": response["uri"], 116 - "cid": response["cid"], 117 - "issueId": response["issueId"], 118 - } 108 + 109 + return CreateIssueResult(repo=repo, issue_id=response["issueId"]) 119 110 120 111 121 112 @tangled_mcp.tool ··· 136 127 "use empty list [] to remove all labels" 137 128 ), 138 129 ] = None, 139 - ) -> dict[str, str]: 130 + ) -> UpdateIssueResult: 140 131 """update an existing issue on a repository 141 132 142 133 Args: ··· 147 138 labels: optional list of label names to SET (replaces existing) 148 139 149 140 Returns: 150 - dict with uri and cid of updated issue 141 + UpdateIssueResult with url (clickable link) and issue_id 151 142 """ 152 143 # resolve owner/repo to (knot, did/repo) 153 144 knot, repo_id = _tangled.resolve_repo_identifier(repo) 154 145 # update_issue doesn't need knot (uses atproto putRecord, not XRPC) 155 - response = _tangled.update_issue(repo_id, issue_id, title, body, labels) 156 - return {"uri": response["uri"], "cid": response["cid"]} 146 + _tangled.update_issue(repo_id, issue_id, title, body, labels) 147 + 148 + return UpdateIssueResult(repo=repo, issue_id=issue_id) 157 149 158 150 159 151 @tangled_mcp.tool ··· 167 159 issue_id: Annotated[ 168 160 int, Field(description="issue number to delete (e.g., 1, 2, 3...)") 169 161 ], 170 - ) -> dict[str, str]: 162 + ) -> DeleteIssueResult: 171 163 """delete an issue from a repository 172 164 173 165 Args: ··· 175 167 issue_id: issue number to delete 176 168 177 169 Returns: 178 - dict with uri of deleted issue 170 + DeleteIssueResult with issue_id of deleted issue 179 171 """ 180 172 # resolve owner/repo to (knot, did/repo) 181 - knot, repo_id = _tangled.resolve_repo_identifier(repo) 173 + _, repo_id = _tangled.resolve_repo_identifier(repo) 182 174 # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) 183 - response = _tangled.delete_issue(repo_id, issue_id) 184 - return {"uri": response["uri"]} 175 + _tangled.delete_issue(repo_id, issue_id) 176 + 177 + return DeleteIssueResult(issue_id=issue_id) 185 178 186 179 187 180 @tangled_mcp.tool ··· 194 187 ], 195 188 limit: Annotated[ 196 189 int, Field(ge=1, le=100, description="maximum number of issues to return") 197 - ] = 50, 190 + ] = 20, 198 191 cursor: Annotated[str | None, Field(description="pagination cursor")] = None, 199 - ) -> dict[str, Any]: 192 + ) -> ListIssuesResult: 200 193 """list issues for a repository 201 194 202 195 Args: ··· 205 198 cursor: optional pagination cursor 206 199 207 200 Returns: 208 - dict with list of issues and optional cursor 201 + ListIssuesResult with list of issues and optional cursor 209 202 """ 210 203 # resolve owner/repo to (knot, did/repo) 211 - knot, repo_id = _tangled.resolve_repo_identifier(repo) 204 + _, repo_id = _tangled.resolve_repo_identifier(repo) 212 205 # list_repo_issues doesn't need knot (queries atproto records, not XRPC) 213 206 response = _tangled.list_repo_issues(repo_id, limit, cursor) 214 207 215 - return { 216 - "issues": response["issues"], 217 - "cursor": response.get("cursor"), 218 - } 208 + return ListIssuesResult.from_api_response(response)
-74
src/tangled_mcp/types.py
··· 1 - """type definitions for tangled MCP server""" 2 - 3 - from typing import Any 4 - 5 - from pydantic import BaseModel, Field 6 - 7 - 8 - class RepoInfo(BaseModel): 9 - """repository information""" 10 - 11 - name: str 12 - knot: str 13 - description: str | None = None 14 - created_at: str = Field(alias="createdAt") 15 - 16 - 17 - class IssueInfo(BaseModel): 18 - """issue information""" 19 - 20 - repo: str 21 - title: str 22 - body: str | None = None 23 - created_at: str = Field(alias="createdAt") 24 - 25 - 26 - class PullInfo(BaseModel): 27 - """pull request information""" 28 - 29 - title: str 30 - body: str | None = None 31 - patch: str 32 - target_repo: str 33 - target_branch: str 34 - source_branch: str | None = None 35 - source_sha: str | None = None 36 - created_at: str = Field(alias="createdAt") 37 - 38 - 39 - class BranchInfo(BaseModel): 40 - """branch information""" 41 - 42 - name: str 43 - sha: str 44 - 45 - 46 - class CreateIssueResult(BaseModel): 47 - """result of creating an issue""" 48 - 49 - uri: str 50 - success: bool = True 51 - message: str = "issue created successfully" 52 - 53 - 54 - class CreateRepoResult(BaseModel): 55 - """result of creating a repository""" 56 - 57 - uri: str 58 - success: bool = True 59 - message: str = "repository created successfully" 60 - 61 - 62 - class ListBranchesResult(BaseModel): 63 - """result of listing branches""" 64 - 65 - branches: list[BranchInfo] 66 - cursor: str | None = None 67 - 68 - 69 - class GenericResult(BaseModel): 70 - """generic operation result""" 71 - 72 - success: bool 73 - message: str 74 - data: dict[str, Any] | None = None
+22
src/tangled_mcp/types/__init__.py
··· 1 + """public types API for tangled MCP server""" 2 + 3 + from tangled_mcp.types._branches import BranchInfo, ListBranchesResult 4 + from tangled_mcp.types._common import RepoIdentifier 5 + from tangled_mcp.types._issues import ( 6 + CreateIssueResult, 7 + DeleteIssueResult, 8 + IssueInfo, 9 + ListIssuesResult, 10 + UpdateIssueResult, 11 + ) 12 + 13 + __all__ = [ 14 + "BranchInfo", 15 + "CreateIssueResult", 16 + "DeleteIssueResult", 17 + "IssueInfo", 18 + "ListBranchesResult", 19 + "ListIssuesResult", 20 + "RepoIdentifier", 21 + "UpdateIssueResult", 22 + ]
+49
src/tangled_mcp/types/_branches.py
··· 1 + """branch-related types""" 2 + 3 + from typing import Any 4 + 5 + from pydantic import BaseModel 6 + 7 + 8 + class BranchInfo(BaseModel): 9 + """branch information""" 10 + 11 + name: str 12 + sha: str 13 + 14 + 15 + class ListBranchesResult(BaseModel): 16 + """result of listing branches""" 17 + 18 + branches: list[BranchInfo] 19 + cursor: str | None = None 20 + 21 + @classmethod 22 + def from_api_response(cls, response: dict[str, Any]) -> "ListBranchesResult": 23 + """construct from raw API response 24 + 25 + Args: 26 + response: raw response from tangled API with structure: 27 + { 28 + "branches": [ 29 + {"reference": {"name": "main", "hash": "abc123"}}, 30 + ... 31 + ], 32 + "cursor": "optional_cursor" 33 + } 34 + 35 + Returns: 36 + ListBranchesResult with parsed branches 37 + """ 38 + branches = [] 39 + if "branches" in response: 40 + for branch_data in response["branches"]: 41 + ref = branch_data.get("reference", {}) 42 + branches.append( 43 + BranchInfo( 44 + name=ref.get("name", ""), 45 + sha=ref.get("hash", ""), 46 + ) 47 + ) 48 + 49 + return cls(branches=branches, cursor=response.get("cursor"))
+18
src/tangled_mcp/types/_common.py
··· 1 + """shared types and validators""" 2 + 3 + from typing import Annotated 4 + 5 + from pydantic import AfterValidator 6 + 7 + 8 + def normalize_repo_identifier(v: str) -> str: 9 + """normalize repo identifier to owner/repo format without @ prefix""" 10 + if "/" not in v: 11 + raise ValueError(f"invalid repo format: '{v}'. expected 'owner/repo'") 12 + owner, repo_name = v.split("/", 1) 13 + # strip @ from owner if present 14 + owner = owner.lstrip("@") 15 + return f"{owner}/{repo_name}" 16 + 17 + 18 + RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]
+90
src/tangled_mcp/types/_issues.py
··· 1 + """issue-related types""" 2 + 3 + from typing import Any 4 + 5 + from pydantic import BaseModel, Field, computed_field 6 + 7 + from tangled_mcp.types._common import RepoIdentifier 8 + 9 + 10 + def _tangled_issue_url(repo: RepoIdentifier, issue_id: int) -> str: 11 + """construct clickable tangled.org URL""" 12 + owner, repo_name = repo.split("/", 1) 13 + return f"https://tangled.org/@{owner}/{repo_name}/issues/{issue_id}" 14 + 15 + 16 + class IssueInfo(BaseModel): 17 + """issue information""" 18 + 19 + uri: str 20 + cid: str 21 + issue_id: int = Field(alias="issueId") 22 + title: str 23 + body: str | None = None 24 + created_at: str = Field(alias="createdAt") 25 + 26 + 27 + class CreateIssueResult(BaseModel): 28 + """result of creating an issue""" 29 + 30 + repo: RepoIdentifier 31 + issue_id: int 32 + 33 + @computed_field 34 + @property 35 + def url(self) -> str: 36 + """construct clickable tangled.org URL""" 37 + return _tangled_issue_url(self.repo, self.issue_id) 38 + 39 + 40 + class UpdateIssueResult(BaseModel): 41 + """result of updating an issue""" 42 + 43 + repo: RepoIdentifier 44 + issue_id: int 45 + 46 + @computed_field 47 + @property 48 + def url(self) -> str: 49 + """construct clickable tangled.org URL""" 50 + return _tangled_issue_url(self.repo, self.issue_id) 51 + 52 + 53 + class DeleteIssueResult(BaseModel): 54 + """result of deleting an issue""" 55 + 56 + issue_id: int 57 + 58 + 59 + class ListIssuesResult(BaseModel): 60 + """result of listing issues""" 61 + 62 + issues: list[IssueInfo] 63 + cursor: str | None = None 64 + 65 + @classmethod 66 + def from_api_response(cls, response: dict[str, Any]) -> "ListIssuesResult": 67 + """construct from raw API response 68 + 69 + Args: 70 + response: raw response from tangled API with structure: 71 + { 72 + "issues": [ 73 + { 74 + "uri": "at://...", 75 + "cid": "bafyrei...", 76 + "issueId": 1, 77 + "title": "...", 78 + "body": "...", 79 + "createdAt": "..." 80 + }, 81 + ... 82 + ], 83 + "cursor": "optional_cursor" 84 + } 85 + 86 + Returns: 87 + ListIssuesResult with parsed issues 88 + """ 89 + issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] 90 + return cls(issues=issues, cursor=response.get("cursor"))
+89
tests/test_types.py
··· 1 + """tests for public types API""" 2 + 3 + import pytest 4 + from pydantic import ValidationError 5 + 6 + from tangled_mcp.types import ( 7 + CreateIssueResult, 8 + ListBranchesResult, 9 + UpdateIssueResult, 10 + ) 11 + 12 + 13 + class TestRepoIdentifierValidation: 14 + """test RepoIdentifier validation behavior""" 15 + 16 + def test_strips_at_prefix(self): 17 + """@ prefix is stripped during validation""" 18 + result = CreateIssueResult(repo="@owner/repo", issue_id=1) 19 + assert result.repo == "owner/repo" 20 + 21 + def test_accepts_without_at_prefix(self): 22 + """repo identifier without @ works""" 23 + result = CreateIssueResult(repo="owner/repo", issue_id=1) 24 + assert result.repo == "owner/repo" 25 + 26 + def test_rejects_invalid_format(self): 27 + """repo identifier without slash is rejected""" 28 + with pytest.raises(ValidationError, match="invalid repo format"): 29 + CreateIssueResult(repo="invalid", issue_id=1) 30 + 31 + 32 + class TestIssueResultURLs: 33 + """test issue result URL generation""" 34 + 35 + def test_create_issue_url(self): 36 + """create result generates correct tangled.org URL""" 37 + result = CreateIssueResult(repo="owner/repo", issue_id=42) 38 + assert result.url == "https://tangled.org/@owner/repo/issues/42" 39 + 40 + def test_update_issue_url(self): 41 + """update result generates correct tangled.org URL""" 42 + result = UpdateIssueResult(repo="owner/repo", issue_id=42) 43 + assert result.url == "https://tangled.org/@owner/repo/issues/42" 44 + 45 + def test_url_handles_at_prefix_input(self): 46 + """URL is correct even when input has @ prefix""" 47 + result = CreateIssueResult(repo="@owner/repo", issue_id=42) 48 + assert result.url == "https://tangled.org/@owner/repo/issues/42" 49 + 50 + 51 + class TestListBranchesFromAPIResponse: 52 + """test ListBranchesResult.from_api_response constructor""" 53 + 54 + def test_parses_branch_data(self): 55 + """parses branches from API response structure""" 56 + response = { 57 + "branches": [ 58 + {"reference": {"name": "main", "hash": "abc123"}}, 59 + {"reference": {"name": "dev", "hash": "def456"}}, 60 + ], 61 + "cursor": "next_page", 62 + } 63 + 64 + result = ListBranchesResult.from_api_response(response) 65 + 66 + assert len(result.branches) == 2 67 + assert result.branches[0].name == "main" 68 + assert result.branches[0].sha == "abc123" 69 + assert result.branches[1].name == "dev" 70 + assert result.branches[1].sha == "def456" 71 + assert result.cursor == "next_page" 72 + 73 + def test_handles_missing_cursor(self): 74 + """cursor is optional in API response""" 75 + response = {"branches": [{"reference": {"name": "main", "hash": "abc123"}}]} 76 + 77 + result = ListBranchesResult.from_api_response(response) 78 + 79 + assert len(result.branches) == 1 80 + assert result.cursor is None 81 + 82 + def test_handles_empty_branches(self): 83 + """handles empty branches list""" 84 + response = {"branches": []} 85 + 86 + result = ListBranchesResult.from_api_response(response) 87 + 88 + assert result.branches == [] 89 + assert result.cursor is None