MCP server for tangled

add owner/repo resolution, switch to uv-dynamic-versioning

+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
··· 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
··· 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
··· 1 1 """tangled MCP server""" 2 2 3 - __version__ = "0.1.0" 3 + try: 4 + from importlib.metadata import version 5 + 6 + __version__ = version("tangled-mcp") 7 + except Exception: 8 + __version__ = "0.0.0"
+2
src/tangled_mcp/_tangled/__init__.py
··· 6 6 get_service_token, 7 7 list_branches, 8 8 list_repo_issues, 9 + resolve_repo_identifier, 9 10 ) 10 11 11 12 __all__ = [ ··· 14 15 "list_branches", 15 16 "create_issue", 16 17 "list_repo_issues", 18 + "resolve_repo_identifier", 17 19 ]
+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
··· 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
··· 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")
-1
uv.lock
··· 1503 1503 1504 1504 [[package]] 1505 1505 name = "tangled-mcp" 1506 - version = "0.1.0" 1507 1506 source = { editable = "." } 1508 1507 dependencies = [ 1509 1508 { name = "atproto" },