MCP server for tangled

fix critical label issues: validation, listing, and visibility

## critical fixes

### 1. fix pydantic field warning
- remove invalid `Field(default=None)` from settings.py
- change to `tangled_pds_url: str | None = None`

### 2. add loud label validation
- new `_validate_labels()` helper checks labels against repo's subscribed definitions
- raises `ValueError` with available labels list when invalid labels provided
- prevents silent failures when creating/updating issues

### 3. include labels in list_repo_issues
- add `labels: list[str]` field to `IssueInfo` model
- fetch and correlate label ops with issues
- return label names (not URIs) for better UX

### 4. add list_repo_labels tool
- new tool to query available labels for a repository
- extracts label names from repo's subscribed label definitions
- helps users discover which labels they can use

## changes

- src/tangled_mcp/settings.py: fix pydantic warning
- src/tangled_mcp/_tangled/_issues.py: add validation, label fetching, new tool
- src/tangled_mcp/types/_issues.py: add labels field to IssueInfo
- src/tangled_mcp/server.py: expose list_repo_labels tool
- src/tangled_mcp/_tangled/__init__.py: export list_repo_labels
- tests/test_server.py: update tool count (5 -> 6)
- README.md: document new tool

all 17 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+151 -2
src
tests
+1
README.md
··· 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 105 - `list_repo_issues(repo, limit, cursor)` - list issues for a repository 106 + - `list_repo_labels(repo)` - list available labels for a repository 106 107 107 108 ## development 108 109
+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:
+23
src/tangled_mcp/server.py
··· 206 206 response = _tangled.list_repo_issues(repo_id, limit, cursor) 207 207 208 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)
+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
+1
src/tangled_mcp/types/_issues.py
··· 22 22 title: str 23 23 body: str | None = None 24 24 created_at: str = Field(alias="createdAt") 25 + labels: list[str] = [] 25 26 26 27 27 28 class CreateIssueResult(BaseModel):
+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"""