MCP server for tangled
at main 260 lines 8.1 kB view raw
1"""tangled MCP server - provides tools and resources for tangled git platform""" 2 3from typing import Annotated 4 5from fastmcp import FastMCP 6from pydantic import Field 7 8from tangled_mcp import _tangled 9from tangled_mcp.types import ( 10 CreateIssueResult, 11 DeleteIssueResult, 12 ListBranchesResult, 13 ListIssuesResult, 14 ListPullsResult, 15 UpdateIssueResult, 16) 17 18tangled_mcp = FastMCP("tangled MCP server") 19 20 21# resources - read-only operations 22@tangled_mcp.resource("tangled://status") 23def tangled_status() -> dict[str, str | bool]: 24 """check the status of the tangled connection""" 25 client = _tangled._get_authenticated_client() 26 27 # verify can get tangled service token 28 try: 29 _tangled.get_service_token() 30 can_access_tangled = True 31 except Exception: 32 can_access_tangled = False 33 34 if not client.me: 35 raise RuntimeError("client not authenticated") 36 37 return { 38 "handle": client.me.handle, 39 "did": client.me.did, 40 "pds_authenticated": True, 41 "tangled_accessible": can_access_tangled, 42 } 43 44 45# tools - actions that query or modify state 46@tangled_mcp.tool 47def list_repo_branches( 48 repo: Annotated[ 49 str, 50 Field( 51 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 52 ), 53 ], 54 limit: Annotated[ 55 int, Field(ge=1, le=100, description="maximum number of branches to return") 56 ] = 50, 57) -> ListBranchesResult: 58 """list branches for a repository 59 60 Args: 61 repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp') 62 limit: maximum number of branches to return (1-100) 63 64 Returns: 65 list of branches 66 """ 67 # resolve owner/repo to (knot, did/repo) 68 knot, repo_id = _tangled.resolve_repo_identifier(repo) 69 response = _tangled.list_branches(knot, repo_id, limit, cursor=None) 70 71 return ListBranchesResult.from_api_response(response) 72 73 74@tangled_mcp.tool 75def create_repo_issue( 76 repo: Annotated[ 77 str, 78 Field( 79 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 80 ), 81 ], 82 title: Annotated[str, Field(description="issue title")], 83 body: Annotated[str | None, Field(description="issue body/description")] = None, 84 labels: Annotated[ 85 list[str] | None, 86 Field( 87 description="optional list of label names (e.g., ['good-first-issue', 'bug']) " 88 "to apply to the issue" 89 ), 90 ] = None, 91) -> CreateIssueResult: 92 """create an issue on a repository 93 94 Args: 95 repo: repository identifier in 'owner/repo' format 96 title: issue title 97 body: optional issue body/description 98 labels: optional list of label names to apply 99 100 Returns: 101 CreateIssueResult with url (clickable link) and issue_id 102 """ 103 # resolve owner/repo to (knot, did/repo) 104 knot, repo_id = _tangled.resolve_repo_identifier(repo) 105 # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 106 response = _tangled.create_issue(repo_id, title, body, labels) 107 108 return CreateIssueResult(repo=repo, id=response["issueId"]) 109 110 111@tangled_mcp.tool 112def update_repo_issue( 113 repo: Annotated[ 114 str, 115 Field( 116 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 117 ), 118 ], 119 issue_id: Annotated[int, Field(description="issue number (e.g., 1, 2, 3...)")], 120 title: Annotated[str | None, Field(description="new issue title")] = None, 121 body: Annotated[str | None, Field(description="new issue body/description")] = None, 122 labels: Annotated[ 123 list[str] | None, 124 Field( 125 description="list of label names to SET (replaces all existing labels). " 126 "use empty list [] to remove all labels" 127 ), 128 ] = None, 129) -> UpdateIssueResult: 130 """update an existing issue on a repository 131 132 Args: 133 repo: repository identifier in 'owner/repo' format 134 issue_id: issue number to update 135 title: optional new title (if None, keeps existing) 136 body: optional new body (if None, keeps existing) 137 labels: optional list of label names to SET (replaces existing) 138 139 Returns: 140 UpdateIssueResult with url (clickable link) and issue_id 141 """ 142 # resolve owner/repo to (knot, did/repo) 143 knot, repo_id = _tangled.resolve_repo_identifier(repo) 144 # update_issue doesn't need knot (uses atproto putRecord, not XRPC) 145 _tangled.update_issue(repo_id, issue_id, title, body, labels) 146 147 return UpdateIssueResult(repo=repo, id=issue_id) 148 149 150@tangled_mcp.tool 151def delete_repo_issue( 152 repo: Annotated[ 153 str, 154 Field( 155 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 156 ), 157 ], 158 issue_id: Annotated[ 159 int, Field(description="issue number to delete (e.g., 1, 2, 3...)") 160 ], 161) -> DeleteIssueResult: 162 """delete an issue from a repository 163 164 Args: 165 repo: repository identifier in 'owner/repo' format 166 issue_id: issue number to delete 167 168 Returns: 169 DeleteIssueResult with issue_id of deleted issue 170 """ 171 # resolve owner/repo to (knot, did/repo) 172 _, repo_id = _tangled.resolve_repo_identifier(repo) 173 # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) 174 _tangled.delete_issue(repo_id, issue_id) 175 176 return DeleteIssueResult(id=issue_id) 177 178 179@tangled_mcp.tool 180def list_repo_issues( 181 repo: Annotated[ 182 str, 183 Field( 184 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 185 ), 186 ], 187 limit: Annotated[ 188 int, Field(ge=1, le=100, description="maximum number of issues to return") 189 ] = 20, 190) -> ListIssuesResult: 191 """list issues for a repository 192 193 Args: 194 repo: repository identifier in 'owner/repo' format 195 limit: maximum number of issues to return (1-100) 196 197 Returns: 198 ListIssuesResult with list of issues 199 """ 200 # resolve owner/repo to (knot, did/repo) 201 _, repo_id = _tangled.resolve_repo_identifier(repo) 202 # list_repo_issues doesn't need knot (queries atproto records, not XRPC) 203 response = _tangled.list_repo_issues(repo_id, limit, cursor=None) 204 205 return ListIssuesResult.from_api_response(response) 206 207 208@tangled_mcp.tool 209def list_repo_labels( 210 repo: Annotated[ 211 str, 212 Field( 213 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 214 ), 215 ], 216) -> list[str]: 217 """list available labels for a repository 218 219 Args: 220 repo: repository identifier in 'owner/repo' format 221 222 Returns: 223 list of available label names for the repository 224 """ 225 # resolve owner/repo to (knot, did/repo) 226 _, repo_id = _tangled.resolve_repo_identifier(repo) 227 # list_repo_labels doesn't need knot (queries atproto records, not XRPC) 228 return _tangled.list_repo_labels(repo_id) 229 230 231@tangled_mcp.tool 232def list_repo_pulls( 233 repo: Annotated[ 234 str, 235 Field( 236 description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 237 ), 238 ], 239 limit: Annotated[ 240 int, Field(ge=1, le=100, description="maximum number of pulls to return") 241 ] = 20, 242) -> ListPullsResult: 243 """list pull requests created by the authenticated user for a repository 244 245 note: only returns PRs that the authenticated user created (tangled stores 246 PRs in the creator's repo, so we can only see our own PRs). 247 248 Args: 249 repo: repository identifier in 'owner/repo' format 250 limit: maximum number of pulls to return (1-100) 251 252 Returns: 253 ListPullsResult with list of pull requests 254 """ 255 # resolve owner/repo to (knot, did/repo) 256 _, repo_id = _tangled.resolve_repo_identifier(repo) 257 # list_repo_pulls doesn't need knot (queries atproto records, not XRPC) 258 response = _tangled.list_repo_pulls(repo_id, limit) 259 260 return ListPullsResult.from_api_response(response["pulls"])