+12
-2
CLAUDE.md
+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
+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
+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
+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
+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
+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"""