···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
···9from tangled_mcp.settings import TANGLED_APPVIEW_URL, TANGLED_DID, settings
101112-def resolve_repo_identifier(owner_slash_repo: str) -> str:
13- """resolve owner/repo format to repository AT-URI
1415 Args:
16- owner_slash_repo: repository identifier in "owner/repo" format
17- (e.g., "zzstoatzz/tangled-mcp")
1819 Returns:
20- repository AT-URI (e.g., "at://did:plc:.../sh.tangled.repo.repo/...")
002122 Raises:
23- ValueError: if format is invalid or repo not found
24 """
25 if "/" not in owner_slash_repo:
26 raise ValueError(
···45 except Exception as e:
46 raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
4748- # query owner's repo collection to find repo by name
49 try:
50 records = client.com.atproto.repo.list_records(
51 models.ComAtprotoRepoListRecords.Params(
52 repo=owner_did,
53- collection="sh.tangled.repo.repo",
54- limit=100, # should be enough for most users
55 )
56 )
57 except Exception as e:
58 raise ValueError(f"failed to list repos for '{owner}': {e}") from e
5960- # find repo with matching name
61 for record in records.records:
62 if hasattr(record.value, "name") and record.value.name == repo_name:
63- return record.uri
0006465 raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'")
66···106def make_tangled_request(
107 method: str,
108 params: dict[str, Any] | None = None,
0109) -> dict[str, Any]:
110- """make an XRPC request to tangled's appview
111112 Args:
113 method: XRPC method (e.g., 'sh.tangled.repo.branches')
114 params: query parameters for the request
0115116 Returns:
117 response data from tangled
118 """
119 token = get_service_token()
120121- url = f"{TANGLED_APPVIEW_URL}/xrpc/{method}"
0000122123 response = httpx.get(
124 url,
···132133134def list_branches(
135- repo: str, limit: int = 50, cursor: str | None = None
136) -> dict[str, Any]:
137 """list branches for a repository
138139 Args:
140- repo: repository identifier (e.g., 'did:plc:.../repoName')
0141 limit: maximum number of branches to return
142 cursor: pagination cursor
143···148 if cursor:
149 params["cursor"] = cursor
150151- return make_tangled_request("sh.tangled.repo.branches", params)
152153154def create_issue(repo: str, title: str, body: str | None = None) -> dict[str, Any]:
···9from tangled_mcp.settings import TANGLED_APPVIEW_URL, TANGLED_DID, settings
101112+def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]:
13+ """resolve owner/repo format to (knot, did/repo) for tangled XRPC
1415 Args:
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")
1819 Returns:
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")
2324 Raises:
25+ ValueError: if format is invalid, handle cannot be resolved, or repo not found
26 """
27 if "/" not in owner_slash_repo:
28 raise ValueError(
···47 except Exception as e:
48 raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
4950+ # query owner's repo collection to find repo and get knot
51 try:
52 records = client.com.atproto.repo.list_records(
53 models.ComAtprotoRepoListRecords.Params(
54 repo=owner_did,
55+ collection="sh.tangled.repo", # correct collection name
56+ limit=100,
57 )
58 )
59 except Exception as e:
60 raise ValueError(f"failed to list repos for '{owner}': {e}") from e
6162+ # find repo with matching name and extract knot
63 for record in records.records:
64 if hasattr(record.value, "name") and record.value.name == repo_name:
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}")
6970 raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'")
71···111def make_tangled_request(
112 method: str,
113 params: dict[str, Any] | None = None,
114+ knot: str | None = None,
115) -> dict[str, Any]:
116+ """make an XRPC request to tangled's knot
117118 Args:
119 method: XRPC method (e.g., 'sh.tangled.repo.branches')
120 params: query parameters for the request
121+ knot: optional knot hostname (if not provided, must be in params["repo"])
122123 Returns:
124 response data from tangled
125 """
126 token = get_service_token()
127128+ # 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}"
133134 response = httpx.get(
135 url,
···143144145def list_branches(
146+ knot: str, repo: str, limit: int = 50, cursor: str | None = None
147) -> dict[str, Any]:
148 """list branches for a repository
149150 Args:
151+ knot: knot hostname (e.g., 'knot1.tangled.sh')
152+ repo: repository identifier in "did/repo" format (e.g., 'did:plc:.../repoName')
153 limit: maximum number of branches to return
154 cursor: pagination cursor
155···160 if cursor:
161 params["cursor"] = cursor
162163+ return make_tangled_request("sh.tangled.repo.branches", params, knot=knot)
164165166def create_issue(repo: str, title: str, body: str | None = None) -> dict[str, Any]:
+11-9
src/tangled_mcp/server.py
···59 Returns:
60 list of branches with optional cursor for pagination
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)
6566 # parse response into BranchInfo objects
67 branches = []
···98 Returns:
99 dict with uri and cid of created issue
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)
0104 return {"uri": response["uri"], "cid": response["cid"]}
105106···127 Returns:
128 dict with list of issues and optional cursor
129 """
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)
0133134 return {
135 "issues": response["issues"],
···59 Returns:
60 list of branches with optional cursor for pagination
61 """
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)
6566 # parse response into BranchInfo objects
67 branches = []
···98 Returns:
99 dict with uri and cid of created issue
100 """
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)
105 return {"uri": response["uri"], "cid": response["cid"]}
106107···128 Returns:
129 dict with list of issues and optional cursor
130 """
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)
135136 return {
137 "issues": response["issues"],
+7-3
tests/test_resolver.py
···31 resolve_repo_identifier("owner/repo")
3233 def test_valid_format_with_at_prefix(self):
34- """test that @owner/repo format is accepted"""
35 from tangled_mcp._tangled._client import resolve_repo_identifier
3637- # this will fail at the resolution step, but not at parsing
38- with pytest.raises(Exception): # will fail during actual resolution
039 resolve_repo_identifier("@owner/repo")
0004041 def test_valid_format_with_did(self):
42 """test that did:plc:.../repo format is accepted"""
···31 resolve_repo_identifier("owner/repo")
3233 def test_valid_format_with_at_prefix(self):
34+ """test that @owner/repo and owner/repo resolve identically"""
35 from tangled_mcp._tangled._client import resolve_repo_identifier
3637+ # 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'"):
40 resolve_repo_identifier("@owner/repo")
41+42+ with pytest.raises(ValueError, match="failed to resolve handle 'owner'"):
43+ resolve_repo_identifier("owner/repo")
4445 def test_valid_format_with_did(self):
46 """test that did:plc:.../repo format is accepted"""