+14
-27
CLAUDE.md
+14
-27
CLAUDE.md
···
1
-
# tangled-mcp project notes
1
+
# tangled-mcp notes
2
2
3
3
## dependencies
4
-
- `uv add` only - NEVER `uv pip`
5
-
- atproto from PR #605 (service auth support)
6
-
7
-
## architecture
8
-
- auth: PDS login → `getServiceAuth` → tangled XRPC
9
-
- `TANGLED_APPVIEW_URL` + `TANGLED_DID` are constants (not user-configurable)
10
-
- `TANGLED_PDS_URL` optional (auto-discovery from handle unless custom PDS)
4
+
- `uv add` only (NEVER `uv pip`)
5
+
- atproto from PR #605 (service auth)
6
+
- pydantic warning filtered (upstream atproto issue #625)
11
7
12
8
## deployment
13
-
- primary development on github (CI/deployment via FastMCP Cloud)
14
-
- mirrored to tangled for dogfooding/showcase
15
-
- single `git push origin main` pushes to both remotes
16
-
- use `just push "message"` for convenience
17
-
- **primary**: https://github.com/zzstoatzz/tangled-mcp
18
-
- **mirror**: git@tangled.sh:zzstoatzz.io/tangled-mcp
19
-
- think of github as source of truth since that's where deployment happens
9
+
- **primary**: https://github.com/zzstoatzz/tangled-mcp (FastMCP Cloud)
10
+
- **mirror**: tangled.sh:zzstoatzz.io/tangled-mcp (dogfooding)
11
+
- `git push origin main` → both remotes
20
12
21
-
## code quality
22
-
- ruff: import sorting (I), pyupgrade (UP)
23
-
- ty: type checking configured
24
-
- pre-commit: ruff only
25
-
- justfile: setup, test, check, push
26
-
27
-
## testing
28
-
- use in-memory transport (pass FastMCP directly to Client)
29
-
- pytest asyncio_mode = "auto" (never add `@pytest.mark.asyncio`)
13
+
## tools
14
+
- all accept `owner/repo` format (e.g., `zzstoatzz/tangled-mcp`)
15
+
- server-side resolution: handle → DID → repo AT-URI
30
16
31
-
## anti-patterns
32
-
- don't expose service URLs as user settings
33
-
- don't use deferred imports (unless absolutely necessary)
17
+
## dev
18
+
- justfile: `setup`, `test`, `check`, `push`
19
+
- versioning: uv-dynamic-versioning (git tags)
20
+
- type checking: ty + ruff (I, UP)
+2
README.md
+2
README.md
···
38
38
39
39
## tools
40
40
41
+
all tools accept repositories in `owner/repo` format (e.g., `zzstoatzz/tangled-mcp`). handles (with or without `@` prefix) and DIDs are both supported for the owner.
42
+
41
43
### repositories
42
44
- `list_repo_branches(repo, limit, cursor)` - list branches for a repository
43
45
+11
-2
pyproject.toml
+11
-2
pyproject.toml
···
1
1
[project]
2
2
name = "tangled-mcp"
3
-
version = "0.1.0"
3
+
dynamic = ["version"]
4
4
description = "MCP server for Tangled git collaboration platform"
5
5
readme = "README.md"
6
6
authors = [{ name = "nate", email = "zzstoatzz@protonmail.com" }]
···
18
18
tangled-mcp = "tangled_mcp.__main__:main"
19
19
20
20
[build-system]
21
-
requires = ["hatchling"]
21
+
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
22
22
build-backend = "hatchling.build"
23
+
24
+
[tool.hatch.version]
25
+
source = "uv-dynamic-versioning"
26
+
27
+
[tool.uv-dynamic-versioning]
28
+
vcs = "git"
29
+
style = "pep440"
30
+
bump = true
31
+
fallback-version = "0.0.0"
23
32
24
33
[tool.hatch.metadata]
25
34
allow-direct-references = true
+6
-1
src/tangled_mcp/__init__.py
+6
-1
src/tangled_mcp/__init__.py
+2
src/tangled_mcp/_tangled/__init__.py
+2
src/tangled_mcp/_tangled/__init__.py
+56
src/tangled_mcp/_tangled/_client.py
+56
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
14
+
15
+
Args:
16
+
owner_slash_repo: repository identifier in "owner/repo" format
17
+
(e.g., "zzstoatzz/tangled-mcp")
18
+
19
+
Returns:
20
+
repository AT-URI (e.g., "at://did:plc:.../sh.tangled.repo.repo/...")
21
+
22
+
Raises:
23
+
ValueError: if format is invalid or repo not found
24
+
"""
25
+
if "/" not in owner_slash_repo:
26
+
raise ValueError(
27
+
f"invalid repo format: '{owner_slash_repo}'. expected 'owner/repo'"
28
+
)
29
+
30
+
owner, repo_name = owner_slash_repo.split("/", 1)
31
+
client = _get_authenticated_client()
32
+
33
+
# resolve owner (handle or DID) to DID
34
+
if owner.startswith("did:"):
35
+
owner_did = owner
36
+
else:
37
+
# strip @ prefix if present
38
+
owner = owner.lstrip("@")
39
+
# resolve handle to DID
40
+
try:
41
+
response = client.com.atproto.identity.resolve_handle(
42
+
params={"handle": owner}
43
+
)
44
+
owner_did = response.did
45
+
except Exception as e:
46
+
raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
47
+
48
+
# 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
59
+
60
+
# 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
64
+
65
+
raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'")
66
+
67
+
12
68
def _get_authenticated_client() -> Client:
13
69
"""get authenticated AT Protocol client
14
70
+19
-9
src/tangled_mcp/server.py
+19
-9
src/tangled_mcp/server.py
···
40
40
def list_repo_branches(
41
41
repo: Annotated[
42
42
str,
43
-
Field(description="repository identifier in format 'did:plc:.../repoName'"),
43
+
Field(
44
+
description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
45
+
),
44
46
],
45
47
limit: Annotated[
46
48
int, Field(ge=1, le=100, description="maximum number of branches to return")
···
50
52
"""list branches for a repository
51
53
52
54
Args:
53
-
repo: repository identifier (e.g., 'did:plc:.../repoName')
55
+
repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')
54
56
limit: maximum number of branches to return (1-100)
55
57
cursor: optional pagination cursor
56
58
57
59
Returns:
58
60
list of branches with optional cursor for pagination
59
61
"""
60
-
response = _tangled.list_branches(repo, limit, cursor)
62
+
# resolve owner/repo to AT-URI
63
+
repo_uri = _tangled.resolve_repo_identifier(repo)
64
+
response = _tangled.list_branches(repo_uri, limit, cursor)
61
65
62
66
# parse response into BranchInfo objects
63
67
branches = []
···
78
82
repo: Annotated[
79
83
str,
80
84
Field(
81
-
description="repository AT-URI (e.g., 'at://did:plc:.../sh.tangled.repo.repo/...')"
85
+
description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
82
86
),
83
87
],
84
88
title: Annotated[str, Field(description="issue title")],
···
87
91
"""create an issue on a repository
88
92
89
93
Args:
90
-
repo: repository AT-URI
94
+
repo: repository identifier in 'owner/repo' format
91
95
title: issue title
92
96
body: optional issue body/description
93
97
94
98
Returns:
95
99
dict with uri and cid of created issue
96
100
"""
97
-
response = _tangled.create_issue(repo, title, body)
101
+
# resolve owner/repo to AT-URI
102
+
repo_uri = _tangled.resolve_repo_identifier(repo)
103
+
response = _tangled.create_issue(repo_uri, title, body)
98
104
return {"uri": response["uri"], "cid": response["cid"]}
99
105
100
106
···
102
108
def list_repo_issues(
103
109
repo: Annotated[
104
110
str,
105
-
Field(description="repository AT-URI to filter issues by"),
111
+
Field(
112
+
description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
113
+
),
106
114
],
107
115
limit: Annotated[
108
116
int, Field(ge=1, le=100, description="maximum number of issues to return")
···
112
120
"""list issues for a repository
113
121
114
122
Args:
115
-
repo: repository AT-URI to filter by
123
+
repo: repository identifier in 'owner/repo' format
116
124
limit: maximum number of issues to return (1-100)
117
125
cursor: optional pagination cursor
118
126
119
127
Returns:
120
128
dict with list of issues and optional cursor
121
129
"""
122
-
response = _tangled.list_repo_issues(repo, limit, cursor)
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)
123
133
124
134
return {
125
135
"issues": response["issues"],
+47
tests/test_resolver.py
+47
tests/test_resolver.py
···
1
+
"""tests for repository identifier resolution"""
2
+
3
+
import pytest
4
+
5
+
6
+
class TestRepoIdentifierParsing:
7
+
"""test repository identifier format validation"""
8
+
9
+
def test_invalid_format_no_slash(self):
10
+
"""test that identifiers without slash are rejected"""
11
+
from tangled_mcp._tangled._client import resolve_repo_identifier
12
+
13
+
with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"):
14
+
resolve_repo_identifier("invalid")
15
+
16
+
def test_invalid_format_empty(self):
17
+
"""test that empty identifiers are rejected"""
18
+
from tangled_mcp._tangled._client import resolve_repo_identifier
19
+
20
+
with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"):
21
+
resolve_repo_identifier("")
22
+
23
+
def test_valid_format_with_handle(self):
24
+
"""test that valid owner/repo format is accepted (parsing only)"""
25
+
# note: we can't actually test resolution without credentials
26
+
# but we can test that the format parsing works
27
+
from tangled_mcp._tangled._client import resolve_repo_identifier
28
+
29
+
# this will fail at the resolution step, but not at parsing
30
+
with pytest.raises(Exception): # will fail during actual resolution
31
+
resolve_repo_identifier("owner/repo")
32
+
33
+
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
36
+
37
+
# this will fail at the resolution step, but not at parsing
38
+
with pytest.raises(Exception): # will fail during actual resolution
39
+
resolve_repo_identifier("@owner/repo")
40
+
41
+
def test_valid_format_with_did(self):
42
+
"""test that did:plc:.../repo format is accepted"""
43
+
from tangled_mcp._tangled._client import resolve_repo_identifier
44
+
45
+
# this will fail at the resolution step, but not at parsing
46
+
with pytest.raises(Exception): # will fail during actual resolution
47
+
resolve_repo_identifier("did:plc:test123/repo")