MCP server for tangled

fix repo resolution: use correct collection and dynamic knot routing

Changed files
+141 -30
docs
src
tangled_mcp
tests
+12 -2
CLAUDE.md
··· 11 11 - `git push origin main` → both remotes 12 12 13 13 ## tools 14 - - all accept `owner/repo` format (e.g., `zzstoatzz/tangled-mcp`) 15 - - server-side resolution: handle → DID → repo AT-URI 14 + - all accept `owner/repo` or `@owner/repo` format (e.g., `zzstoatzz/tangled-mcp`) 15 + - server-side resolution: 16 + 1. handle → DID (via atproto identity resolution) 17 + 2. query `sh.tangled.repo` collection on owner's PDS 18 + 3. extract knot hostname and repo name from record 19 + 4. call knot's XRPC endpoint (e.g., `https://knot1.tangled.sh/xrpc/...`) 16 20 17 21 ## dev 18 22 - justfile: `setup`, `test`, `check`, `push` 19 23 - versioning: uv-dynamic-versioning (git tags) 20 24 - type checking: ty + ruff (I, UP) 25 + 26 + ## architecture notes 27 + - repos stored as atproto records in collection `sh.tangled.repo` (NOT `sh.tangled.repo.repo`) 28 + - each repo record contains `knot` field indicating hosting server 29 + - appview (tangled.org) uses web routes, NOT XRPC 30 + - knots (e.g., knot1.tangled.sh) expose XRPC endpoints for git operations
+81
docs/architecture.md
··· 1 + # architecture 2 + 3 + ## tangled platform overview 4 + 5 + tangled is a git collaboration platform built on the AT Protocol. it consists of: 6 + 7 + ### components 8 + 9 + - **appview** (tangled.org): web interface using traditional HTTP routes 10 + - handles OAuth authentication for browser users 11 + - serves HTML/CSS/JS for the UI 12 + - proxies git operations to knots 13 + - does NOT expose XRPC endpoints 14 + 15 + - **knots** (e.g., knot1.tangled.sh): git hosting servers 16 + - expose XRPC endpoints for git operations 17 + - host actual git repositories 18 + - handle git-upload-pack, git-receive-pack, etc. 19 + 20 + - **PDS** (Personal Data Server): AT Protocol user data storage 21 + - stores user's atproto records 22 + - repos stored in `sh.tangled.repo` collection 23 + - each repo record contains: name, knot, description, etc. 24 + 25 + ### data flow 26 + 27 + 1. user creates repo on tangled.org 28 + 2. appview writes repo record to user's PDS (`sh.tangled.repo` collection) 29 + 3. repo record includes `knot` field indicating which knot hosts it 30 + 4. git operations routed through appview to the appropriate knot 31 + 32 + ## MCP server implementation 33 + 34 + ### resolution flow 35 + 36 + when a client calls `list_repo_branches("@owner/repo")`: 37 + 38 + 1. **normalize input**: strip @ if present (`@owner` → `owner`) 39 + 40 + 2. **resolve handle to DID**: 41 + - if already DID format: use as-is 42 + - otherwise: call `com.atproto.identity.resolveHandle` 43 + - result: `did:plc:...` 44 + 45 + 3. **query repo collection**: 46 + - call `com.atproto.repo.listRecords` on owner's PDS 47 + - collection: `sh.tangled.repo` (NOT `sh.tangled.repo.repo`) 48 + - find record where `name` matches repo name 49 + 50 + 4. **extract knot**: 51 + - get `knot` field from repo record 52 + - example: `knot1.tangled.sh` 53 + 54 + 5. **call knot XRPC**: 55 + - construct URL: `https://{knot}/xrpc/sh.tangled.repo.branches` 56 + - params: `{"repo": "{did}/{repo_name}", "limit": N}` 57 + - auth: service token from `com.atproto.server.getServiceAuth` 58 + 59 + ### authentication 60 + 61 + uses AT Protocol service auth: 62 + 63 + 1. authenticate to user's PDS with handle/password 64 + 2. call `com.atproto.server.getServiceAuth` with `aud: did:web:tangled.org` 65 + 3. receive service token (60 second expiration) 66 + 4. use token in `Authorization: Bearer {token}` header for XRPC calls 67 + 68 + ### key implementation details 69 + 70 + - **collection name**: `sh.tangled.repo` 71 + - **knot resolution**: dynamic based on repo record, not hardcoded 72 + - **handle formats**: both `owner/repo` and `@owner/repo` accepted 73 + - **private implementation**: resolution logic in `_tangled/` package 74 + - **public API**: clean tool interface in `server.py` 75 + 76 + ### error handling 77 + 78 + - invalid format: `ValueError` with clear message 79 + - handle not found: `ValueError` from identity resolution 80 + - repo not found: `ValueError` after querying collection 81 + - XRPC errors: raised from httpx with status code
+2
pyproject.toml
··· 55 55 filterwarnings = [ 56 56 # upstream atproto issue: https://github.com/MarshalX/atproto/issues/625 57 57 "ignore::pydantic.warnings.UnsupportedFieldAttributeWarning", 58 + # upstream: atproto SDK uses deprecated httpx API 59 + "ignore::DeprecationWarning:httpx", 58 60 ] 59 61 60 62 [tool.ty.src]
+28 -16
src/tangled_mcp/_tangled/_client.py
··· 9 9 from tangled_mcp.settings import TANGLED_APPVIEW_URL, TANGLED_DID, settings 10 10 11 11 12 - def resolve_repo_identifier(owner_slash_repo: str) -> str: 13 - """resolve owner/repo format to repository AT-URI 12 + def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]: 13 + """resolve owner/repo format to (knot, did/repo) for tangled XRPC 14 14 15 15 Args: 16 - owner_slash_repo: repository identifier in "owner/repo" format 17 - (e.g., "zzstoatzz/tangled-mcp") 16 + owner_slash_repo: repository identifier in "owner/repo" or "@owner/repo" format 17 + (e.g., "zzstoatzz.io/tangled-mcp" or "@zzstoatzz.io/tangled-mcp") 18 18 19 19 Returns: 20 - repository AT-URI (e.g., "at://did:plc:.../sh.tangled.repo.repo/...") 20 + tuple of (knot_url, repo_identifier) where: 21 + - knot_url: hostname of knot hosting the repo (e.g., "knot1.tangled.sh") 22 + - repo_identifier: "did/repo" format (e.g., "did:plc:.../tangled-mcp") 21 23 22 24 Raises: 23 - ValueError: if format is invalid or repo not found 25 + ValueError: if format is invalid, handle cannot be resolved, or repo not found 24 26 """ 25 27 if "/" not in owner_slash_repo: 26 28 raise ValueError( ··· 45 47 except Exception as e: 46 48 raise ValueError(f"failed to resolve handle '{owner}': {e}") from e 47 49 48 - # query owner's repo collection to find repo by name 50 + # query owner's repo collection to find repo and get knot 49 51 try: 50 52 records = client.com.atproto.repo.list_records( 51 53 models.ComAtprotoRepoListRecords.Params( 52 54 repo=owner_did, 53 - collection="sh.tangled.repo.repo", 54 - limit=100, # should be enough for most users 55 + collection="sh.tangled.repo", # correct collection name 56 + limit=100, 55 57 ) 56 58 ) 57 59 except Exception as e: 58 60 raise ValueError(f"failed to list repos for '{owner}': {e}") from e 59 61 60 - # find repo with matching name 62 + # find repo with matching name and extract knot 61 63 for record in records.records: 62 64 if hasattr(record.value, "name") and record.value.name == repo_name: 63 - return record.uri 65 + knot = getattr(record.value, "knot", None) 66 + if not knot: 67 + raise ValueError(f"repo '{repo_name}' has no knot information") 68 + return (knot, f"{owner_did}/{repo_name}") 64 69 65 70 raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'") 66 71 ··· 106 111 def make_tangled_request( 107 112 method: str, 108 113 params: dict[str, Any] | None = None, 114 + knot: str | None = None, 109 115 ) -> dict[str, Any]: 110 - """make an XRPC request to tangled's appview 116 + """make an XRPC request to tangled's knot 111 117 112 118 Args: 113 119 method: XRPC method (e.g., 'sh.tangled.repo.branches') 114 120 params: query parameters for the request 121 + knot: optional knot hostname (if not provided, must be in params["repo"]) 115 122 116 123 Returns: 117 124 response data from tangled 118 125 """ 119 126 token = get_service_token() 120 127 121 - url = f"{TANGLED_APPVIEW_URL}/xrpc/{method}" 128 + # if knot not provided, extract from repo identifier 129 + if not knot and params and "repo" in params: 130 + raise ValueError("knot must be provided or repo must be resolved first") 131 + 132 + url = f"https://{knot}/xrpc/{method}" 122 133 123 134 response = httpx.get( 124 135 url, ··· 132 143 133 144 134 145 def list_branches( 135 - repo: str, limit: int = 50, cursor: str | None = None 146 + knot: str, repo: str, limit: int = 50, cursor: str | None = None 136 147 ) -> dict[str, Any]: 137 148 """list branches for a repository 138 149 139 150 Args: 140 - repo: repository identifier (e.g., 'did:plc:.../repoName') 151 + knot: knot hostname (e.g., 'knot1.tangled.sh') 152 + repo: repository identifier in "did/repo" format (e.g., 'did:plc:.../repoName') 141 153 limit: maximum number of branches to return 142 154 cursor: pagination cursor 143 155 ··· 148 160 if cursor: 149 161 params["cursor"] = cursor 150 162 151 - return make_tangled_request("sh.tangled.repo.branches", params) 163 + return make_tangled_request("sh.tangled.repo.branches", params, knot=knot) 152 164 153 165 154 166 def create_issue(repo: str, title: str, body: str | None = None) -> dict[str, Any]:
+11 -9
src/tangled_mcp/server.py
··· 59 59 Returns: 60 60 list of branches with optional cursor for pagination 61 61 """ 62 - # resolve owner/repo to AT-URI 63 - repo_uri = _tangled.resolve_repo_identifier(repo) 64 - response = _tangled.list_branches(repo_uri, limit, cursor) 62 + # resolve owner/repo to (knot, did/repo) 63 + knot, repo_id = _tangled.resolve_repo_identifier(repo) 64 + response = _tangled.list_branches(knot, repo_id, limit, cursor) 65 65 66 66 # parse response into BranchInfo objects 67 67 branches = [] ··· 98 98 Returns: 99 99 dict with uri and cid of created issue 100 100 """ 101 - # resolve owner/repo to AT-URI 102 - repo_uri = _tangled.resolve_repo_identifier(repo) 103 - response = _tangled.create_issue(repo_uri, title, body) 101 + # resolve owner/repo to (knot, did/repo) 102 + knot, repo_id = _tangled.resolve_repo_identifier(repo) 103 + # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 104 + response = _tangled.create_issue(repo_id, title, body) 104 105 return {"uri": response["uri"], "cid": response["cid"]} 105 106 106 107 ··· 127 128 Returns: 128 129 dict with list of issues and optional cursor 129 130 """ 130 - # resolve owner/repo to AT-URI 131 - repo_uri = _tangled.resolve_repo_identifier(repo) 132 - response = _tangled.list_repo_issues(repo_uri, limit, cursor) 131 + # resolve owner/repo to (knot, did/repo) 132 + knot, repo_id = _tangled.resolve_repo_identifier(repo) 133 + # list_repo_issues doesn't need knot (queries atproto records, not XRPC) 134 + response = _tangled.list_repo_issues(repo_id, limit, cursor) 133 135 134 136 return { 135 137 "issues": response["issues"],
+7 -3
tests/test_resolver.py
··· 31 31 resolve_repo_identifier("owner/repo") 32 32 33 33 def test_valid_format_with_at_prefix(self): 34 - """test that @owner/repo format is accepted""" 34 + """test that @owner/repo and owner/repo resolve identically""" 35 35 from tangled_mcp._tangled._client import resolve_repo_identifier 36 36 37 - # this will fail at the resolution step, but not at parsing 38 - with pytest.raises(Exception): # will fail during actual resolution 37 + # both formats should behave the same (@ is stripped internally) 38 + # they'll both fail resolution with fake handle, but in the same way 39 + with pytest.raises(ValueError, match="failed to resolve handle 'owner'"): 39 40 resolve_repo_identifier("@owner/repo") 41 + 42 + with pytest.raises(ValueError, match="failed to resolve handle 'owner'"): 43 + resolve_repo_identifier("owner/repo") 40 44 41 45 def test_valid_format_with_did(self): 42 46 """test that did:plc:.../repo format is accepted"""