···1111- `git push origin main` → both remotes
12121313## tools
1414-- all accept `owner/repo` format (e.g., `zzstoatzz/tangled-mcp`)
1515-- server-side resolution: handle → DID → repo AT-URI
1414+- all accept `owner/repo` or `@owner/repo` format (e.g., `zzstoatzz/tangled-mcp`)
1515+- server-side resolution:
1616+ 1. handle → DID (via atproto identity resolution)
1717+ 2. query `sh.tangled.repo` collection on owner's PDS
1818+ 3. extract knot hostname and repo name from record
1919+ 4. call knot's XRPC endpoint (e.g., `https://knot1.tangled.sh/xrpc/...`)
16201721## dev
1822- justfile: `setup`, `test`, `check`, `push`
1923- versioning: uv-dynamic-versioning (git tags)
2024- type checking: ty + ruff (I, UP)
2525+2626+## architecture notes
2727+- repos stored as atproto records in collection `sh.tangled.repo` (NOT `sh.tangled.repo.repo`)
2828+- each repo record contains `knot` field indicating hosting server
2929+- appview (tangled.org) uses web routes, NOT XRPC
3030+- knots (e.g., knot1.tangled.sh) expose XRPC endpoints for git operations
+81
docs/architecture.md
···11+# architecture
22+33+## tangled platform overview
44+55+tangled is a git collaboration platform built on the AT Protocol. it consists of:
66+77+### components
88+99+- **appview** (tangled.org): web interface using traditional HTTP routes
1010+ - handles OAuth authentication for browser users
1111+ - serves HTML/CSS/JS for the UI
1212+ - proxies git operations to knots
1313+ - does NOT expose XRPC endpoints
1414+1515+- **knots** (e.g., knot1.tangled.sh): git hosting servers
1616+ - expose XRPC endpoints for git operations
1717+ - host actual git repositories
1818+ - handle git-upload-pack, git-receive-pack, etc.
1919+2020+- **PDS** (Personal Data Server): AT Protocol user data storage
2121+ - stores user's atproto records
2222+ - repos stored in `sh.tangled.repo` collection
2323+ - each repo record contains: name, knot, description, etc.
2424+2525+### data flow
2626+2727+1. user creates repo on tangled.org
2828+2. appview writes repo record to user's PDS (`sh.tangled.repo` collection)
2929+3. repo record includes `knot` field indicating which knot hosts it
3030+4. git operations routed through appview to the appropriate knot
3131+3232+## MCP server implementation
3333+3434+### resolution flow
3535+3636+when a client calls `list_repo_branches("@owner/repo")`:
3737+3838+1. **normalize input**: strip @ if present (`@owner` → `owner`)
3939+4040+2. **resolve handle to DID**:
4141+ - if already DID format: use as-is
4242+ - otherwise: call `com.atproto.identity.resolveHandle`
4343+ - result: `did:plc:...`
4444+4545+3. **query repo collection**:
4646+ - call `com.atproto.repo.listRecords` on owner's PDS
4747+ - collection: `sh.tangled.repo` (NOT `sh.tangled.repo.repo`)
4848+ - find record where `name` matches repo name
4949+5050+4. **extract knot**:
5151+ - get `knot` field from repo record
5252+ - example: `knot1.tangled.sh`
5353+5454+5. **call knot XRPC**:
5555+ - construct URL: `https://{knot}/xrpc/sh.tangled.repo.branches`
5656+ - params: `{"repo": "{did}/{repo_name}", "limit": N}`
5757+ - auth: service token from `com.atproto.server.getServiceAuth`
5858+5959+### authentication
6060+6161+uses AT Protocol service auth:
6262+6363+1. authenticate to user's PDS with handle/password
6464+2. call `com.atproto.server.getServiceAuth` with `aud: did:web:tangled.org`
6565+3. receive service token (60 second expiration)
6666+4. use token in `Authorization: Bearer {token}` header for XRPC calls
6767+6868+### key implementation details
6969+7070+- **collection name**: `sh.tangled.repo`
7171+- **knot resolution**: dynamic based on repo record, not hardcoded
7272+- **handle formats**: both `owner/repo` and `@owner/repo` accepted
7373+- **private implementation**: resolution logic in `_tangled/` package
7474+- **public API**: clean tool interface in `server.py`
7575+7676+### error handling
7777+7878+- invalid format: `ValueError` with clear message
7979+- handle not found: `ValueError` from identity resolution
8080+- repo not found: `ValueError` after querying collection
8181+- XRPC errors: raised from httpx with status code
···99from tangled_mcp.settings import TANGLED_APPVIEW_URL, TANGLED_DID, settings
101011111212-def resolve_repo_identifier(owner_slash_repo: str) -> str:
1313- """resolve owner/repo format to repository AT-URI
1212+def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]:
1313+ """resolve owner/repo format to (knot, did/repo) for tangled XRPC
14141515 Args:
1616- owner_slash_repo: repository identifier in "owner/repo" format
1717- (e.g., "zzstoatzz/tangled-mcp")
1616+ owner_slash_repo: repository identifier in "owner/repo" or "@owner/repo" format
1717+ (e.g., "zzstoatzz.io/tangled-mcp" or "@zzstoatzz.io/tangled-mcp")
18181919 Returns:
2020- repository AT-URI (e.g., "at://did:plc:.../sh.tangled.repo.repo/...")
2020+ tuple of (knot_url, repo_identifier) where:
2121+ - knot_url: hostname of knot hosting the repo (e.g., "knot1.tangled.sh")
2222+ - repo_identifier: "did/repo" format (e.g., "did:plc:.../tangled-mcp")
21232224 Raises:
2323- ValueError: if format is invalid or repo not found
2525+ ValueError: if format is invalid, handle cannot be resolved, or repo not found
2426 """
2527 if "/" not in owner_slash_repo:
2628 raise ValueError(
···4547 except Exception as e:
4648 raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
47494848- # query owner's repo collection to find repo by name
5050+ # query owner's repo collection to find repo and get knot
4951 try:
5052 records = client.com.atproto.repo.list_records(
5153 models.ComAtprotoRepoListRecords.Params(
5254 repo=owner_did,
5353- collection="sh.tangled.repo.repo",
5454- limit=100, # should be enough for most users
5555+ collection="sh.tangled.repo", # correct collection name
5656+ limit=100,
5557 )
5658 )
5759 except Exception as e:
5860 raise ValueError(f"failed to list repos for '{owner}': {e}") from e
59616060- # find repo with matching name
6262+ # find repo with matching name and extract knot
6163 for record in records.records:
6264 if hasattr(record.value, "name") and record.value.name == repo_name:
6363- return record.uri
6565+ knot = getattr(record.value, "knot", None)
6666+ if not knot:
6767+ raise ValueError(f"repo '{repo_name}' has no knot information")
6868+ return (knot, f"{owner_did}/{repo_name}")
64696570 raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'")
6671···106111def make_tangled_request(
107112 method: str,
108113 params: dict[str, Any] | None = None,
114114+ knot: str | None = None,
109115) -> dict[str, Any]:
110110- """make an XRPC request to tangled's appview
116116+ """make an XRPC request to tangled's knot
111117112118 Args:
113119 method: XRPC method (e.g., 'sh.tangled.repo.branches')
114120 params: query parameters for the request
121121+ knot: optional knot hostname (if not provided, must be in params["repo"])
115122116123 Returns:
117124 response data from tangled
118125 """
119126 token = get_service_token()
120127121121- url = f"{TANGLED_APPVIEW_URL}/xrpc/{method}"
128128+ # if knot not provided, extract from repo identifier
129129+ if not knot and params and "repo" in params:
130130+ raise ValueError("knot must be provided or repo must be resolved first")
131131+132132+ url = f"https://{knot}/xrpc/{method}"
122133123134 response = httpx.get(
124135 url,
···132143133144134145def list_branches(
135135- repo: str, limit: int = 50, cursor: str | None = None
146146+ knot: str, repo: str, limit: int = 50, cursor: str | None = None
136147) -> dict[str, Any]:
137148 """list branches for a repository
138149139150 Args:
140140- repo: repository identifier (e.g., 'did:plc:.../repoName')
151151+ knot: knot hostname (e.g., 'knot1.tangled.sh')
152152+ repo: repository identifier in "did/repo" format (e.g., 'did:plc:.../repoName')
141153 limit: maximum number of branches to return
142154 cursor: pagination cursor
143155···148160 if cursor:
149161 params["cursor"] = cursor
150162151151- return make_tangled_request("sh.tangled.repo.branches", params)
163163+ return make_tangled_request("sh.tangled.repo.branches", params, knot=knot)
152164153165154166def create_issue(repo: str, title: str, body: str | None = None) -> dict[str, Any]:
+11-9
src/tangled_mcp/server.py
···5959 Returns:
6060 list of branches with optional cursor for pagination
6161 """
6262- # resolve owner/repo to AT-URI
6363- repo_uri = _tangled.resolve_repo_identifier(repo)
6464- response = _tangled.list_branches(repo_uri, limit, cursor)
6262+ # resolve owner/repo to (knot, did/repo)
6363+ knot, repo_id = _tangled.resolve_repo_identifier(repo)
6464+ response = _tangled.list_branches(knot, repo_id, limit, cursor)
65656666 # parse response into BranchInfo objects
6767 branches = []
···9898 Returns:
9999 dict with uri and cid of created issue
100100 """
101101- # resolve owner/repo to AT-URI
102102- repo_uri = _tangled.resolve_repo_identifier(repo)
103103- response = _tangled.create_issue(repo_uri, title, body)
101101+ # resolve owner/repo to (knot, did/repo)
102102+ knot, repo_id = _tangled.resolve_repo_identifier(repo)
103103+ # create_issue doesn't need knot (uses atproto putRecord, not XRPC)
104104+ response = _tangled.create_issue(repo_id, title, body)
104105 return {"uri": response["uri"], "cid": response["cid"]}
105106106107···127128 Returns:
128129 dict with list of issues and optional cursor
129130 """
130130- # resolve owner/repo to AT-URI
131131- repo_uri = _tangled.resolve_repo_identifier(repo)
132132- response = _tangled.list_repo_issues(repo_uri, limit, cursor)
131131+ # resolve owner/repo to (knot, did/repo)
132132+ knot, repo_id = _tangled.resolve_repo_identifier(repo)
133133+ # list_repo_issues doesn't need knot (queries atproto records, not XRPC)
134134+ response = _tangled.list_repo_issues(repo_id, limit, cursor)
133135134136 return {
135137 "issues": response["issues"],
+7-3
tests/test_resolver.py
···3131 resolve_repo_identifier("owner/repo")
32323333 def test_valid_format_with_at_prefix(self):
3434- """test that @owner/repo format is accepted"""
3434+ """test that @owner/repo and owner/repo resolve identically"""
3535 from tangled_mcp._tangled._client import resolve_repo_identifier
36363737- # this will fail at the resolution step, but not at parsing
3838- with pytest.raises(Exception): # will fail during actual resolution
3737+ # both formats should behave the same (@ is stripped internally)
3838+ # they'll both fail resolution with fake handle, but in the same way
3939+ with pytest.raises(ValueError, match="failed to resolve handle 'owner'"):
3940 resolve_repo_identifier("@owner/repo")
4141+4242+ with pytest.raises(ValueError, match="failed to resolve handle 'owner'"):
4343+ resolve_repo_identifier("owner/repo")
40444145 def test_valid_format_with_did(self):
4246 """test that did:plc:.../repo format is accepted"""