···11-# tangled-mcp project notes
11+# tangled-mcp notes
2233## dependencies
44-- `uv add` only - NEVER `uv pip`
55-- atproto from PR #605 (service auth support)
66-77-## architecture
88-- auth: PDS login → `getServiceAuth` → tangled XRPC
99-- `TANGLED_APPVIEW_URL` + `TANGLED_DID` are constants (not user-configurable)
1010-- `TANGLED_PDS_URL` optional (auto-discovery from handle unless custom PDS)
44+- `uv add` only (NEVER `uv pip`)
55+- atproto from PR #605 (service auth)
66+- pydantic warning filtered (upstream atproto issue #625)
117128## deployment
1313-- primary development on github (CI/deployment via FastMCP Cloud)
1414-- mirrored to tangled for dogfooding/showcase
1515-- single `git push origin main` pushes to both remotes
1616-- use `just push "message"` for convenience
1717-- **primary**: https://github.com/zzstoatzz/tangled-mcp
1818-- **mirror**: git@tangled.sh:zzstoatzz.io/tangled-mcp
1919-- think of github as source of truth since that's where deployment happens
99+- **primary**: https://github.com/zzstoatzz/tangled-mcp (FastMCP Cloud)
1010+- **mirror**: tangled.sh:zzstoatzz.io/tangled-mcp (dogfooding)
1111+- `git push origin main` → both remotes
20122121-## code quality
2222-- ruff: import sorting (I), pyupgrade (UP)
2323-- ty: type checking configured
2424-- pre-commit: ruff only
2525-- justfile: setup, test, check, push
2626-2727-## testing
2828-- use in-memory transport (pass FastMCP directly to Client)
2929-- pytest asyncio_mode = "auto" (never add `@pytest.mark.asyncio`)
1313+## tools
1414+- all accept `owner/repo` format (e.g., `zzstoatzz/tangled-mcp`)
1515+- server-side resolution: handle → DID → repo AT-URI
30163131-## anti-patterns
3232-- don't expose service URLs as user settings
3333-- don't use deferred imports (unless absolutely necessary)
1717+## dev
1818+- justfile: `setup`, `test`, `check`, `push`
1919+- versioning: uv-dynamic-versioning (git tags)
2020+- type checking: ty + ruff (I, UP)
+2
README.md
···38383939## tools
40404141+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.
4242+4143### repositories
4244- `list_repo_branches(repo, limit, cursor)` - list branches for a repository
4345
···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
1414+1515+ Args:
1616+ owner_slash_repo: repository identifier in "owner/repo" format
1717+ (e.g., "zzstoatzz/tangled-mcp")
1818+1919+ Returns:
2020+ repository AT-URI (e.g., "at://did:plc:.../sh.tangled.repo.repo/...")
2121+2222+ Raises:
2323+ ValueError: if format is invalid or repo not found
2424+ """
2525+ if "/" not in owner_slash_repo:
2626+ raise ValueError(
2727+ f"invalid repo format: '{owner_slash_repo}'. expected 'owner/repo'"
2828+ )
2929+3030+ owner, repo_name = owner_slash_repo.split("/", 1)
3131+ client = _get_authenticated_client()
3232+3333+ # resolve owner (handle or DID) to DID
3434+ if owner.startswith("did:"):
3535+ owner_did = owner
3636+ else:
3737+ # strip @ prefix if present
3838+ owner = owner.lstrip("@")
3939+ # resolve handle to DID
4040+ try:
4141+ response = client.com.atproto.identity.resolve_handle(
4242+ params={"handle": owner}
4343+ )
4444+ owner_did = response.did
4545+ except Exception as e:
4646+ raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
4747+4848+ # query owner's repo collection to find repo by name
4949+ try:
5050+ records = client.com.atproto.repo.list_records(
5151+ models.ComAtprotoRepoListRecords.Params(
5252+ repo=owner_did,
5353+ collection="sh.tangled.repo.repo",
5454+ limit=100, # should be enough for most users
5555+ )
5656+ )
5757+ except Exception as e:
5858+ raise ValueError(f"failed to list repos for '{owner}': {e}") from e
5959+6060+ # find repo with matching name
6161+ for record in records.records:
6262+ if hasattr(record.value, "name") and record.value.name == repo_name:
6363+ return record.uri
6464+6565+ raise ValueError(f"repo '{repo_name}' not found for owner '{owner}'")
6666+6767+1268def _get_authenticated_client() -> Client:
1369 """get authenticated AT Protocol client
1470
+19-9
src/tangled_mcp/server.py
···4040def list_repo_branches(
4141 repo: Annotated[
4242 str,
4343- Field(description="repository identifier in format 'did:plc:.../repoName'"),
4343+ Field(
4444+ description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
4545+ ),
4446 ],
4547 limit: Annotated[
4648 int, Field(ge=1, le=100, description="maximum number of branches to return")
···5052 """list branches for a repository
51535254 Args:
5353- repo: repository identifier (e.g., 'did:plc:.../repoName')
5555+ repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')
5456 limit: maximum number of branches to return (1-100)
5557 cursor: optional pagination cursor
56585759 Returns:
5860 list of branches with optional cursor for pagination
5961 """
6060- response = _tangled.list_branches(repo, limit, cursor)
6262+ # resolve owner/repo to AT-URI
6363+ repo_uri = _tangled.resolve_repo_identifier(repo)
6464+ response = _tangled.list_branches(repo_uri, limit, cursor)
61656266 # parse response into BranchInfo objects
6367 branches = []
···7882 repo: Annotated[
7983 str,
8084 Field(
8181- description="repository AT-URI (e.g., 'at://did:plc:.../sh.tangled.repo.repo/...')"
8585+ description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
8286 ),
8387 ],
8488 title: Annotated[str, Field(description="issue title")],
···8791 """create an issue on a repository
88928993 Args:
9090- repo: repository AT-URI
9494+ repo: repository identifier in 'owner/repo' format
9195 title: issue title
9296 body: optional issue body/description
93979498 Returns:
9599 dict with uri and cid of created issue
96100 """
9797- response = _tangled.create_issue(repo, title, body)
101101+ # resolve owner/repo to AT-URI
102102+ repo_uri = _tangled.resolve_repo_identifier(repo)
103103+ response = _tangled.create_issue(repo_uri, title, body)
98104 return {"uri": response["uri"], "cid": response["cid"]}
99105100106···102108def list_repo_issues(
103109 repo: Annotated[
104110 str,
105105- Field(description="repository AT-URI to filter issues by"),
111111+ Field(
112112+ description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
113113+ ),
106114 ],
107115 limit: Annotated[
108116 int, Field(ge=1, le=100, description="maximum number of issues to return")
···112120 """list issues for a repository
113121114122 Args:
115115- repo: repository AT-URI to filter by
123123+ repo: repository identifier in 'owner/repo' format
116124 limit: maximum number of issues to return (1-100)
117125 cursor: optional pagination cursor
118126119127 Returns:
120128 dict with list of issues and optional cursor
121129 """
122122- response = _tangled.list_repo_issues(repo, limit, cursor)
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)
123133124134 return {
125135 "issues": response["issues"],
+47
tests/test_resolver.py
···11+"""tests for repository identifier resolution"""
22+33+import pytest
44+55+66+class TestRepoIdentifierParsing:
77+ """test repository identifier format validation"""
88+99+ def test_invalid_format_no_slash(self):
1010+ """test that identifiers without slash are rejected"""
1111+ from tangled_mcp._tangled._client import resolve_repo_identifier
1212+1313+ with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"):
1414+ resolve_repo_identifier("invalid")
1515+1616+ def test_invalid_format_empty(self):
1717+ """test that empty identifiers are rejected"""
1818+ from tangled_mcp._tangled._client import resolve_repo_identifier
1919+2020+ with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"):
2121+ resolve_repo_identifier("")
2222+2323+ def test_valid_format_with_handle(self):
2424+ """test that valid owner/repo format is accepted (parsing only)"""
2525+ # note: we can't actually test resolution without credentials
2626+ # but we can test that the format parsing works
2727+ from tangled_mcp._tangled._client import resolve_repo_identifier
2828+2929+ # this will fail at the resolution step, but not at parsing
3030+ with pytest.raises(Exception): # will fail during actual resolution
3131+ resolve_repo_identifier("owner/repo")
3232+3333+ def test_valid_format_with_at_prefix(self):
3434+ """test that @owner/repo format is accepted"""
3535+ from tangled_mcp._tangled._client import resolve_repo_identifier
3636+3737+ # this will fail at the resolution step, but not at parsing
3838+ with pytest.raises(Exception): # will fail during actual resolution
3939+ resolve_repo_identifier("@owner/repo")
4040+4141+ def test_valid_format_with_did(self):
4242+ """test that did:plc:.../repo format is accepted"""
4343+ from tangled_mcp._tangled._client import resolve_repo_identifier
4444+4545+ # this will fail at the resolution step, but not at parsing
4646+ with pytest.raises(Exception): # will fail during actual resolution
4747+ resolve_repo_identifier("did:plc:test123/repo")