MCP server for tangled

lots a goodies #1

merged opened by zzstoatzz.io targeting main from refactor/types-and-urls
  • refactor types.py into dir
  • grokability for LLMs
  • better typing in general
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xbtmt2zjwlrfegqvch7fboei/sh.tangled.repo.pull/3m2vnx5mjql22
+615 -120
Diff #0
+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
+31 -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` 80 81 82 + </details> 83 + 81 84 ### development usage 82 85 83 86 ```bash 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + - `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels 104 + - `delete_repo_issue(repo, issue_id)` - delete an issue 105 + - `list_repo_issues(repo, limit, cursor)` - list issues for a repository 106 + - `list_repo_labels(repo)` - list available labels for a repository 107 + 108 + ## development 109 +
+52 -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 - ) 72 + return ListBranchesResult.from_api_response(response) 77 73 78 - return ListBranchesResult(branches=branches, cursor=response.get("cursor")) 79 74 80 - 81 75 @tangled_mcp.tool 82 76 83 77 ··· 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 - } 119 108 109 + return CreateIssueResult(repo=repo, issue_id=response["issueId"]) 120 110 111 + 121 112 @tangled_mcp.tool 122 113 123 114 ··· 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) 157 147 148 + return UpdateIssueResult(repo=repo, issue_id=issue_id) 158 149 150 + 159 151 @tangled_mcp.tool 160 152 161 153 ··· 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) 185 176 177 + return DeleteIssueResult(issue_id=issue_id) 186 178 179 + 187 180 @tangled_mcp.tool 188 181 189 182 ··· 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) 209 + 210 + 211 + @tangled_mcp.tool 212 + def list_repo_labels( 213 + repo: Annotated[ 214 + str, 215 + Field( 216 + description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 217 + ), 218 + ], 219 + ) -> list[str]: 220 + """list available labels for a repository 221 + 222 + Args: 223 + repo: repository identifier in 'owner/repo' format 224 + 225 + Returns: 226 + list of available label names for the repository 227 + """ 228 + # resolve owner/repo to (knot, did/repo) 229 + _, repo_id = _tangled.resolve_repo_identifier(repo) 230 + # list_repo_labels doesn't need knot (queries atproto records, not XRPC) 231 + return _tangled.list_repo_labels(repo_id)
-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)]
+91
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 + labels: list[str] = [] 26 + 27 + 28 + class CreateIssueResult(BaseModel): 29 + """result of creating an issue""" 30 + 31 + repo: RepoIdentifier 32 + issue_id: int 33 + 34 + @computed_field 35 + @property 36 + def url(self) -> str: 37 + """construct clickable tangled.org URL""" 38 + return _tangled_issue_url(self.repo, self.issue_id) 39 + 40 + 41 + class UpdateIssueResult(BaseModel): 42 + """result of updating an issue""" 43 + 44 + repo: RepoIdentifier 45 + issue_id: int 46 + 47 + @computed_field 48 + @property 49 + def url(self) -> str: 50 + """construct clickable tangled.org URL""" 51 + return _tangled_issue_url(self.repo, self.issue_id) 52 + 53 + 54 + class DeleteIssueResult(BaseModel): 55 + """result of deleting an issue""" 56 + 57 + issue_id: int 58 + 59 + 60 + class ListIssuesResult(BaseModel): 61 + """result of listing issues""" 62 + 63 + issues: list[IssueInfo] 64 + cursor: str | None = None 65 + 66 + @classmethod 67 + def from_api_response(cls, response: dict[str, Any]) -> "ListIssuesResult": 68 + """construct from raw API response 69 + 70 + Args: 71 + response: raw response from tangled API with structure: 72 + { 73 + "issues": [ 74 + { 75 + "uri": "at://...", 76 + "cid": "bafyrei...", 77 + "issueId": 1, 78 + "title": "...", 79 + "body": "...", 80 + "createdAt": "..." 81 + }, 82 + ... 83 + ], 84 + "cursor": "optional_cursor" 85 + } 86 + 87 + Returns: 88 + ListIssuesResult with parsed issues 89 + """ 90 + issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] 91 + 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
+2
src/tangled_mcp/_tangled/__init__.py
··· 10 10 create_issue, 11 11 delete_issue, 12 12 list_repo_issues, 13 + list_repo_labels, 13 14 update_issue, 14 15 ) 15 16 ··· 21 22 "update_issue", 22 23 "delete_issue", 23 24 "list_repo_issues", 25 + "list_repo_labels", 24 26 "resolve_repo_identifier", 25 27 ]
+121
src/tangled_mcp/_tangled/_issues.py
··· 377 377 378 378 # filter issues by repo 379 379 issues = [] 380 + issue_uris = [] 380 381 for record in response.records: 381 382 if ( 382 383 repo := getattr(record.value, "repo", None) 383 384 ) is not None and repo == repo_at_uri: 385 + issue_uris.append(record.uri) 384 386 issues.append( 385 387 { 386 388 "uri": record.uri, ··· 389 391 "title": getattr(record.value, "title", ""), 390 392 "body": getattr(record.value, "body", None), 391 393 "createdAt": getattr(record.value, "createdAt", ""), 394 + "labels": [], # will be populated below 392 395 } 393 396 ) 394 397 398 + # fetch label ops and correlate with issues 399 + if issue_uris: 400 + label_ops = client.com.atproto.repo.list_records( 401 + models.ComAtprotoRepoListRecords.Params( 402 + repo=client.me.did, 403 + collection="sh.tangled.label.op", 404 + limit=100, 405 + ) 406 + ) 407 + 408 + # build map of issue_uri -> current label URIs 409 + issue_labels_map: dict[str, set[str]] = {uri: set() for uri in issue_uris} 410 + for op_record in label_ops.records: 411 + if hasattr(op_record.value, "subject") and op_record.value.subject in issue_labels_map: 412 + subject_uri = op_record.value.subject 413 + if hasattr(op_record.value, "add"): 414 + for operand in op_record.value.add: 415 + if hasattr(operand, "key"): 416 + issue_labels_map[subject_uri].add(operand.key) 417 + if hasattr(op_record.value, "delete"): 418 + for operand in op_record.value.delete: 419 + if hasattr(operand, "key"): 420 + issue_labels_map[subject_uri].discard(operand.key) 421 + 422 + # extract label names from URIs and add to issues 423 + for issue in issues: 424 + label_uris = issue_labels_map.get(issue["uri"], set()) 425 + issue["labels"] = [uri.split("/")[-1] for uri in label_uris] 426 + 395 427 return {"issues": issues, "cursor": response.cursor} 396 428 397 429 430 + def list_repo_labels(repo_id: str) -> list[str]: 431 + """list available labels for a repository 432 + 433 + Args: 434 + repo_id: repository identifier in "did/repo" format 435 + 436 + Returns: 437 + list of available label names for the repo 438 + """ 439 + client = _get_authenticated_client() 440 + 441 + if not client.me: 442 + raise RuntimeError("client not authenticated") 443 + 444 + # parse repo_id to get owner_did and repo_name 445 + if "/" not in repo_id: 446 + raise ValueError(f"invalid repo_id format: {repo_id}") 447 + 448 + owner_did, repo_name = repo_id.split("/", 1) 449 + 450 + # get the repo's subscribed label definitions 451 + records = client.com.atproto.repo.list_records( 452 + models.ComAtprotoRepoListRecords.Params( 453 + repo=owner_did, 454 + collection="sh.tangled.repo", 455 + limit=100, 456 + ) 457 + ) 458 + 459 + repo_labels: list[str] = [] 460 + for record in records.records: 461 + if ( 462 + name := getattr(record.value, "name", None) 463 + ) is not None and name == repo_name: 464 + if (subscribed_labels := getattr(record.value, "labels", None)) is not None: 465 + # extract label names from URIs 466 + repo_labels = [uri.split("/")[-1] for uri in subscribed_labels] 467 + break 468 + 469 + if not repo_labels and not any( 470 + (name := getattr(r.value, "name", None)) and name == repo_name 471 + for r in records.records 472 + ): 473 + raise ValueError(f"repo not found: {repo_id}") 474 + 475 + return repo_labels 476 + 477 + 398 478 def _get_current_labels(client, issue_uri: str) -> set[str]: 399 479 """get current labels applied to an issue by examining all label ops""" 400 480 label_ops = client.com.atproto.repo.list_records( ··· 421 501 return current_labels 422 502 423 503 504 + def _validate_labels(labels: list[str], repo_labels: list[str]) -> None: 505 + """validate that all requested labels exist in the repo's subscribed labels 506 + 507 + Args: 508 + labels: list of label names or URIs to validate 509 + repo_labels: list of label definition URIs the repo subscribes to 510 + 511 + Raises: 512 + ValueError: if any labels are invalid, listing available labels 513 + """ 514 + # extract available label names from repo's subscribed label URIs 515 + available_labels = [uri.split("/")[-1] for uri in repo_labels] 516 + 517 + # check each requested label 518 + invalid_labels = [] 519 + for label in labels: 520 + if label.startswith("at://"): 521 + # if it's a full URI, check if it's in repo_labels 522 + if label not in repo_labels: 523 + invalid_labels.append(label) 524 + else: 525 + # if it's a name, check if it matches any available label 526 + if not any( 527 + label.lower() == available.lower() for available in available_labels 528 + ): 529 + invalid_labels.append(label) 530 + 531 + # fail loudly if any labels are invalid 532 + if invalid_labels: 533 + raise ValueError( 534 + f"invalid labels: {invalid_labels}\n" 535 + f"available labels for this repo: {sorted(available_labels)}" 536 + ) 537 + 538 + 424 539 def _apply_labels( 425 540 client, 426 541 issue_uri: str, ··· 436 551 labels: list of label names or URIs to apply 437 552 repo_labels: list of label definition URIs the repo subscribes to 438 553 current_labels: set of currently applied label URIs 554 + 555 + Raises: 556 + ValueError: if any labels are invalid (via _validate_labels) 439 557 """ 558 + # validate labels before attempting to apply 559 + _validate_labels(labels, repo_labels) 560 + 440 561 # resolve label names to URIs 441 562 new_label_uris = set() 442 563 for label in labels:
+1 -1
src/tangled_mcp/settings.py
··· 10 10 11 11 # optional: specify PDS URL if auto-discovery doesn't work 12 12 # leave empty for auto-discovery from handle 13 - tangled_pds_url: str | None = Field(default=None) 13 + tangled_pds_url: str | None = None 14 14 15 15 16 16 # tangled service constants
+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) == 5 25 + assert len(tools) == 6 26 26 27 27 tool_names = {tool.name for tool in tools} 28 28 assert "list_repo_branches" in tool_names ··· 30 30 assert "update_repo_issue" in tool_names 31 31 assert "delete_repo_issue" in tool_names 32 32 assert "list_repo_issues" in tool_names 33 + assert "list_repo_labels" in tool_names 33 34 34 35 async def test_list_repo_branches_tool_schema(self): 35 36 """test list_repo_branches tool has correct schema"""

History

1 round 0 comments
sign up or login to add to the discussion
zzstoatzz.io submitted #0
2 commits
expand
refactor types and improve issue operations
fix critical label issues: validation, listing, and visibility
expand 0 comments
pull request successfully merged