MCP server for tangled

add issue management: update_repo_issue and delete_repo_issue tools with labels support

- add labels parameter to create_repo_issue
- implement update_repo_issue (update title, body, and/or labels)
- implement delete_repo_issue
- refactor code into _client.py (auth, repo, branches) and _issues.py
- fix variable collision bug (walrus operator shadowing function parameter)
- update README with all tools

+589 -168
+3 -1
README.md
··· 44 44 - `list_repo_branches(repo, limit, cursor)` - list branches for a repository 45 45 46 46 ### issues 47 - - `create_repo_issue(repo, title, body)` - create an issue on a repository 47 + - `create_repo_issue(repo, title, body, labels)` - create an issue with optional labels 48 + - `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels 49 + - `delete_repo_issue(repo, issue_id)` - delete an issue 48 50 - `list_repo_issues(repo, limit, cursor)` - list issues for a repository 49 51 50 52 ## development
+8 -2
src/tangled_mcp/_tangled/__init__.py
··· 2 2 3 3 from tangled_mcp._tangled._client import ( 4 4 _get_authenticated_client, 5 - create_issue, 6 5 get_service_token, 7 6 list_branches, 8 - list_repo_issues, 9 7 resolve_repo_identifier, 10 8 ) 9 + from tangled_mcp._tangled._issues import ( 10 + create_issue, 11 + delete_issue, 12 + list_repo_issues, 13 + update_issue, 14 + ) 11 15 12 16 __all__ = [ 13 17 "_get_authenticated_client", 14 18 "get_service_token", 15 19 "list_branches", 16 20 "create_issue", 21 + "update_issue", 22 + "delete_issue", 17 23 "list_repo_issues", 18 24 "resolve_repo_identifier", 19 25 ]
+4 -159
src/tangled_mcp/_tangled/_client.py
··· 1 - """tangled XRPC client implementation""" 1 + """tangled XRPC client - core auth, repo resolution, and branch operations""" 2 2 3 - from datetime import datetime, timezone 4 3 from typing import Any 5 4 6 5 import httpx 7 6 from atproto import Client, models 8 7 9 - from tangled_mcp.settings import TANGLED_APPVIEW_URL, TANGLED_DID, settings 8 + from tangled_mcp.settings import TANGLED_DID, settings 10 9 11 10 12 11 def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]: ··· 61 60 62 61 # find repo with matching name and extract knot 63 62 for record in records.records: 64 - if hasattr(record.value, "name") and record.value.name == repo_name: 63 + if (name := getattr(record.value, "name", None)) and name == repo_name: 65 64 knot = getattr(record.value, "knot", None) 66 65 if not knot: 67 66 raise ValueError(f"repo '{repo_name}' has no knot information") ··· 163 162 return make_tangled_request("sh.tangled.repo.branches", params, knot=knot) 164 163 165 164 166 - def create_issue(repo_id: str, title: str, body: str | None = None) -> dict[str, Any]: 167 - """create an issue on a repository 168 - 169 - Args: 170 - repo_id: repository identifier in "did/repo" format (e.g., 'did:plc:.../tangled-mcp') 171 - title: issue title 172 - body: optional issue body/description 173 - 174 - Returns: 175 - dict with uri and cid of created issue record 176 - """ 177 - client = _get_authenticated_client() 178 - 179 - if not client.me: 180 - raise RuntimeError("client not authenticated") 181 - 182 - # parse repo_id to get owner_did and repo_name 183 - if "/" not in repo_id: 184 - raise ValueError(f"invalid repo_id format: {repo_id}") 185 - 186 - owner_did, repo_name = repo_id.split("/", 1) 187 - 188 - # get the repo AT-URI by querying the repo collection 189 - records = client.com.atproto.repo.list_records( 190 - models.ComAtprotoRepoListRecords.Params( 191 - repo=owner_did, 192 - collection="sh.tangled.repo", 193 - limit=100, 194 - ) 195 - ) 196 - 197 - repo_at_uri = None 198 - for record in records.records: 199 - if hasattr(record.value, "name") and record.value.name == repo_name: 200 - repo_at_uri = record.uri 201 - break 202 - 203 - if not repo_at_uri: 204 - raise ValueError(f"repo not found: {repo_id}") 205 - 206 - # query existing issues to determine next issueId 207 - existing_issues = client.com.atproto.repo.list_records( 208 - models.ComAtprotoRepoListRecords.Params( 209 - repo=client.me.did, 210 - collection="sh.tangled.repo.issue", 211 - limit=100, 212 - ) 213 - ) 214 - 215 - # find max issueId for this repo 216 - max_issue_id = 0 217 - for issue_record in existing_issues.records: 218 - if hasattr(issue_record.value, "repo") and issue_record.value.repo == repo_at_uri: 219 - issue_id = getattr(issue_record.value, "issueId", None) 220 - if issue_id is not None: 221 - max_issue_id = max(max_issue_id, issue_id) 222 - 223 - next_issue_id = max_issue_id + 1 224 - 225 - # generate timestamp ID for rkey 226 - tid = int(datetime.now(timezone.utc).timestamp() * 1000000) 227 - rkey = str(tid) 228 - 229 - # create issue record with proper schema 230 - record = { 231 - "$type": "sh.tangled.repo.issue", 232 - "repo": repo_at_uri, # full AT-URI of repo record 233 - "issueId": next_issue_id, # sequential issue ID 234 - "owner": client.me.did, # issue creator's DID 235 - "title": title, 236 - "body": body, 237 - "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 238 - } 239 - 240 - # use putRecord to create the issue 241 - response = client.com.atproto.repo.put_record( 242 - models.ComAtprotoRepoPutRecord.Data( 243 - repo=client.me.did, 244 - collection="sh.tangled.repo.issue", 245 - rkey=rkey, 246 - record=record, 247 - ) 248 - ) 249 - 250 - return {"uri": response.uri, "cid": response.cid, "issueId": next_issue_id} 251 - 252 - 253 - def list_repo_issues( 254 - repo_id: str, limit: int = 50, cursor: str | None = None 255 - ) -> dict[str, Any]: 256 - """list issues for a repository 257 - 258 - Args: 259 - repo_id: repository identifier in "did/repo" format 260 - limit: maximum number of issues to return 261 - cursor: pagination cursor 262 - 263 - Returns: 264 - dict containing issues and optional cursor 265 - """ 266 - client = _get_authenticated_client() 267 - 268 - if not client.me: 269 - raise RuntimeError("client not authenticated") 270 - 271 - # parse repo_id to get owner_did and repo_name 272 - if "/" not in repo_id: 273 - raise ValueError(f"invalid repo_id format: {repo_id}") 274 - 275 - owner_did, repo_name = repo_id.split("/", 1) 276 - 277 - # get the repo AT-URI by querying the repo collection 278 - records = client.com.atproto.repo.list_records( 279 - models.ComAtprotoRepoListRecords.Params( 280 - repo=owner_did, 281 - collection="sh.tangled.repo", 282 - limit=100, 283 - ) 284 - ) 285 - 286 - repo_at_uri = None 287 - for record in records.records: 288 - if hasattr(record.value, "name") and record.value.name == repo_name: 289 - repo_at_uri = record.uri 290 - break 291 - 292 - if not repo_at_uri: 293 - raise ValueError(f"repo not found: {repo_id}") 294 - 295 - # list records from the issue collection 296 - response = client.com.atproto.repo.list_records( 297 - models.ComAtprotoRepoListRecords.Params( 298 - repo=client.me.did, 299 - collection="sh.tangled.repo.issue", 300 - limit=limit, 301 - cursor=cursor, 302 - ) 303 - ) 304 - 305 - # filter issues by repo 306 - issues = [] 307 - for record in response.records: 308 - if hasattr(record.value, "repo") and record.value.repo == repo_at_uri: 309 - issues.append( 310 - { 311 - "uri": record.uri, 312 - "cid": record.cid, 313 - "issueId": getattr(record.value, "issueId", 0), 314 - "title": getattr(record.value, "title", ""), 315 - "body": getattr(record.value, "body", None), 316 - "createdAt": getattr(record.value, "createdAt", ""), 317 - } 318 - ) 319 - 320 - return {"issues": issues, "cursor": response.cursor} 165 + # issue operations have been moved to _issues.py
+484
src/tangled_mcp/_tangled/_issues.py
··· 1 + """issue operations for tangled""" 2 + 3 + from datetime import datetime, timezone 4 + from typing import Any 5 + 6 + from atproto import models 7 + 8 + from tangled_mcp._tangled._client import _get_authenticated_client 9 + 10 + 11 + def create_issue( 12 + repo_id: str, title: str, body: str | None = None, labels: list[str] | None = None 13 + ) -> dict[str, Any]: 14 + """create an issue on a repository 15 + 16 + Args: 17 + repo_id: repository identifier in "did/repo" format (e.g., 'did:plc:.../tangled-mcp') 18 + title: issue title 19 + body: optional issue body/description 20 + labels: optional list of label names (e.g., ["good-first-issue", "bug"]) 21 + or full label definition URIs (e.g., ["at://did:.../sh.tangled.label.definition/bug"]) 22 + 23 + Returns: 24 + dict with uri, cid, and issueId of created issue record 25 + """ 26 + client = _get_authenticated_client() 27 + 28 + if not client.me: 29 + raise RuntimeError("client not authenticated") 30 + 31 + # parse repo_id to get owner_did and repo_name 32 + if "/" not in repo_id: 33 + raise ValueError(f"invalid repo_id format: {repo_id}") 34 + 35 + owner_did, repo_name = repo_id.split("/", 1) 36 + 37 + # get the repo AT-URI and label definitions by querying the repo collection 38 + records = client.com.atproto.repo.list_records( 39 + models.ComAtprotoRepoListRecords.Params( 40 + repo=owner_did, 41 + collection="sh.tangled.repo", 42 + limit=100, 43 + ) 44 + ) 45 + 46 + repo_at_uri = None 47 + repo_labels: list[str] = [] 48 + for record in records.records: 49 + if ( 50 + name := getattr(record.value, "name", None) 51 + ) is not None and name == repo_name: 52 + repo_at_uri = record.uri 53 + # get repo's subscribed labels 54 + if ( 55 + subscribed_labels := getattr(record.value, "labels", None) 56 + ) is not None: 57 + repo_labels = subscribed_labels 58 + break 59 + 60 + if not repo_at_uri: 61 + raise ValueError(f"repo not found: {repo_id}") 62 + 63 + # query existing issues to determine next issueId 64 + existing_issues = client.com.atproto.repo.list_records( 65 + models.ComAtprotoRepoListRecords.Params( 66 + repo=client.me.did, 67 + collection="sh.tangled.repo.issue", 68 + limit=100, 69 + ) 70 + ) 71 + 72 + # find max issueId for this repo 73 + max_issue_id = 0 74 + for issue_record in existing_issues.records: 75 + if ( 76 + repo := getattr(issue_record.value, "repo", None) 77 + ) is not None and repo == repo_at_uri: 78 + issue_id = getattr(issue_record.value, "issueId", None) 79 + if issue_id is not None: 80 + max_issue_id = max(max_issue_id, issue_id) 81 + 82 + next_issue_id = max_issue_id + 1 83 + 84 + # generate timestamp ID for rkey 85 + tid = int(datetime.now(timezone.utc).timestamp() * 1000000) 86 + rkey = str(tid) 87 + 88 + # create issue record with proper schema 89 + record = { 90 + "$type": "sh.tangled.repo.issue", 91 + "repo": repo_at_uri, # full AT-URI of repo record 92 + "issueId": next_issue_id, # sequential issue ID 93 + "owner": client.me.did, # issue creator's DID 94 + "title": title, 95 + "body": body, 96 + "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 97 + } 98 + 99 + # use putRecord to create the issue 100 + response = client.com.atproto.repo.put_record( 101 + models.ComAtprotoRepoPutRecord.Data( 102 + repo=client.me.did, 103 + collection="sh.tangled.repo.issue", 104 + rkey=rkey, 105 + record=record, 106 + ) 107 + ) 108 + 109 + issue_uri = response.uri 110 + result = {"uri": issue_uri, "cid": response.cid, "issueId": next_issue_id} 111 + 112 + # if labels were specified, create a label op to add them 113 + if labels: 114 + _apply_labels(client, issue_uri, labels, repo_labels, current_labels=set()) 115 + 116 + return result 117 + 118 + 119 + def update_issue( 120 + repo_id: str, 121 + issue_id: int, 122 + title: str | None = None, 123 + body: str | None = None, 124 + labels: list[str] | None = None, 125 + ) -> dict[str, Any]: 126 + """update an existing issue on a repository 127 + 128 + Args: 129 + repo_id: repository identifier in "did/repo" format (e.g., 'did:plc:.../tangled-mcp') 130 + issue_id: the sequential issue number (e.g., 1, 2, 3...) 131 + title: optional new issue title (if None, keeps existing) 132 + body: optional new issue body (if None, keeps existing) 133 + labels: optional list of label names to SET (replaces all existing labels) 134 + use empty list [] to remove all labels 135 + 136 + Returns: 137 + dict with uri and cid of updated issue record 138 + """ 139 + client = _get_authenticated_client() 140 + 141 + if not client.me: 142 + raise RuntimeError("client not authenticated") 143 + 144 + # parse repo_id to get owner_did and repo_name 145 + if "/" not in repo_id: 146 + raise ValueError(f"invalid repo_id format: {repo_id}") 147 + 148 + owner_did, repo_name = repo_id.split("/", 1) 149 + 150 + # get the repo AT-URI and label definitions 151 + records = client.com.atproto.repo.list_records( 152 + models.ComAtprotoRepoListRecords.Params( 153 + repo=owner_did, 154 + collection="sh.tangled.repo", 155 + limit=100, 156 + ) 157 + ) 158 + 159 + repo_at_uri = None 160 + repo_labels: list[str] = [] 161 + for record in records.records: 162 + if (name := getattr(record.value, "name", None)) and name == repo_name: 163 + repo_at_uri = record.uri 164 + if ( 165 + subscribed_labels := getattr(record.value, "labels", None) 166 + ) is not None: 167 + repo_labels = subscribed_labels 168 + break 169 + 170 + if not repo_at_uri: 171 + raise ValueError(f"repo not found: {repo_id}") 172 + 173 + # find the issue record with matching issueId 174 + existing_issues = client.com.atproto.repo.list_records( 175 + models.ComAtprotoRepoListRecords.Params( 176 + repo=client.me.did, 177 + collection="sh.tangled.repo.issue", 178 + limit=100, 179 + ) 180 + ) 181 + 182 + issue_record = None 183 + issue_rkey = None 184 + for record in existing_issues.records: 185 + if ( 186 + (repo := getattr(record.value, "repo", None)) is not None 187 + and repo == repo_at_uri 188 + and (_issue_id := getattr(record.value, "issueId", None)) is not None 189 + and _issue_id == issue_id 190 + ): 191 + issue_record = record 192 + issue_rkey = record.uri.split("/")[-1] # extract rkey from AT-URI 193 + break 194 + 195 + if not issue_record: 196 + raise ValueError(f"issue #{issue_id} not found in repo {repo_id}") 197 + 198 + # update the issue fields (keep existing if not specified) 199 + updated_record = { 200 + "$type": "sh.tangled.repo.issue", 201 + "repo": repo_at_uri, 202 + "issueId": issue_id, 203 + "owner": ( 204 + (owner := getattr(issue_record.value, "owner", None)) is not None and owner 205 + if hasattr(issue_record.value, "owner") 206 + else client.me.did 207 + ), 208 + "title": title 209 + if title is not None 210 + else getattr(issue_record.value, "title", None), 211 + "body": body if body is not None else getattr(issue_record.value, "body", None), 212 + "createdAt": getattr(issue_record.value, "createdAt", None), 213 + } 214 + 215 + # get current CID for swap 216 + current_cid = issue_record.cid 217 + 218 + # update the issue record 219 + 220 + if issue_rkey is None: 221 + raise ValueError( 222 + f"issue rkey not found for issue #{issue_id} in repo {repo_id}" 223 + ) 224 + 225 + response = client.com.atproto.repo.put_record( 226 + models.ComAtprotoRepoPutRecord.Data( 227 + repo=client.me.did, 228 + collection="sh.tangled.repo.issue", 229 + rkey=issue_rkey, 230 + record=updated_record, 231 + swap_record=current_cid, # ensure we're updating the right version 232 + ) 233 + ) 234 + 235 + result = {"uri": response.uri, "cid": response.cid} 236 + 237 + # if labels were specified, create a label op to set them 238 + if labels is not None: 239 + issue_uri = response.uri 240 + 241 + # get current label state for this issue 242 + current_labels = _get_current_labels(client, issue_uri) 243 + 244 + # apply the new label state 245 + _apply_labels(client, issue_uri, labels, repo_labels, current_labels) 246 + 247 + return result 248 + 249 + 250 + def delete_issue(repo_id: str, issue_id: int) -> dict[str, str]: 251 + """delete an issue from a repository 252 + 253 + Args: 254 + repo_id: repository identifier in "did/repo" format (e.g., 'did:plc:.../tangled-mcp') 255 + issue_id: the sequential issue number (e.g., 1, 2, 3...) 256 + 257 + Returns: 258 + dict with uri of deleted issue record 259 + """ 260 + client = _get_authenticated_client() 261 + 262 + if not client.me: 263 + raise RuntimeError("client not authenticated") 264 + 265 + # parse repo_id to get owner_did and repo_name 266 + if "/" not in repo_id: 267 + raise ValueError(f"invalid repo_id format: {repo_id}") 268 + 269 + owner_did, repo_name = repo_id.split("/", 1) 270 + 271 + # get the repo AT-URI 272 + records = client.com.atproto.repo.list_records( 273 + models.ComAtprotoRepoListRecords.Params( 274 + repo=owner_did, 275 + collection="sh.tangled.repo", 276 + limit=100, 277 + ) 278 + ) 279 + 280 + repo_at_uri = None 281 + for record in records.records: 282 + if ( 283 + name := getattr(record.value, "name", None) 284 + ) is not None and name == repo_name: 285 + repo_at_uri = record.uri 286 + break 287 + 288 + if not repo_at_uri: 289 + raise ValueError(f"repo not found: {repo_id}") 290 + 291 + # find the issue record with matching issueId 292 + existing_issues = client.com.atproto.repo.list_records( 293 + models.ComAtprotoRepoListRecords.Params( 294 + repo=client.me.did, 295 + collection="sh.tangled.repo.issue", 296 + limit=100, 297 + ) 298 + ) 299 + 300 + issue_uri = None 301 + issue_rkey = None 302 + for record in existing_issues.records: 303 + if ( 304 + (repo := getattr(record.value, "repo", None)) is not None 305 + and repo == repo_at_uri 306 + and (_issue_id := getattr(record.value, "issueId", None)) is not None 307 + and _issue_id == issue_id 308 + ): 309 + issue_uri = record.uri 310 + issue_rkey = record.uri.split("/")[-1] 311 + break 312 + 313 + if not issue_uri or not issue_rkey: 314 + raise ValueError(f"issue #{issue_id} not found in repo {repo_id}") 315 + 316 + # delete the issue record 317 + client.com.atproto.repo.delete_record( 318 + models.ComAtprotoRepoDeleteRecord.Data( 319 + repo=client.me.did, 320 + collection="sh.tangled.repo.issue", 321 + rkey=issue_rkey, 322 + ) 323 + ) 324 + 325 + return {"uri": issue_uri} 326 + 327 + 328 + def list_repo_issues( 329 + repo_id: str, limit: int = 50, cursor: str | None = None 330 + ) -> dict[str, Any]: 331 + """list issues for a repository 332 + 333 + Args: 334 + repo_id: repository identifier in "did/repo" format 335 + limit: maximum number of issues to return 336 + cursor: pagination cursor 337 + 338 + Returns: 339 + dict containing issues and optional cursor 340 + """ 341 + client = _get_authenticated_client() 342 + 343 + if not client.me: 344 + raise RuntimeError("client not authenticated") 345 + 346 + # parse repo_id to get owner_did and repo_name 347 + if "/" not in repo_id: 348 + raise ValueError(f"invalid repo_id format: {repo_id}") 349 + 350 + owner_did, repo_name = repo_id.split("/", 1) 351 + 352 + # get the repo AT-URI by querying the repo collection 353 + records = client.com.atproto.repo.list_records( 354 + models.ComAtprotoRepoListRecords.Params( 355 + repo=owner_did, 356 + collection="sh.tangled.repo", 357 + limit=100, 358 + ) 359 + ) 360 + 361 + repo_at_uri = None 362 + for record in records.records: 363 + if ( 364 + name := getattr(record.value, "name", None) 365 + ) is not None and name == repo_name: 366 + repo_at_uri = record.uri 367 + break 368 + 369 + if not repo_at_uri: 370 + raise ValueError(f"repo not found: {repo_id}") 371 + 372 + # list records from the issue collection 373 + response = client.com.atproto.repo.list_records( 374 + models.ComAtprotoRepoListRecords.Params( 375 + repo=client.me.did, 376 + collection="sh.tangled.repo.issue", 377 + limit=limit, 378 + cursor=cursor, 379 + ) 380 + ) 381 + 382 + # filter issues by repo 383 + issues = [] 384 + for record in response.records: 385 + if ( 386 + repo := getattr(record.value, "repo", None) 387 + ) is not None and repo == repo_at_uri: 388 + issues.append( 389 + { 390 + "uri": record.uri, 391 + "cid": record.cid, 392 + "issueId": getattr(record.value, "issueId", 0), 393 + "title": getattr(record.value, "title", ""), 394 + "body": getattr(record.value, "body", None), 395 + "createdAt": getattr(record.value, "createdAt", ""), 396 + } 397 + ) 398 + 399 + return {"issues": issues, "cursor": response.cursor} 400 + 401 + 402 + def _get_current_labels(client, issue_uri: str) -> set[str]: 403 + """get current labels applied to an issue by examining all label ops""" 404 + label_ops = client.com.atproto.repo.list_records( 405 + models.ComAtprotoRepoListRecords.Params( 406 + repo=client.me.did, 407 + collection="sh.tangled.label.op", 408 + limit=100, 409 + ) 410 + ) 411 + 412 + # collect all label ops for this issue to determine current state 413 + current_labels = set() 414 + for op_record in label_ops.records: 415 + if hasattr(op_record.value, "subject") and op_record.value.subject == issue_uri: 416 + if hasattr(op_record.value, "add"): 417 + for operand in op_record.value.add: 418 + if hasattr(operand, "key"): 419 + current_labels.add(operand.key) 420 + if hasattr(op_record.value, "delete"): 421 + for operand in op_record.value.delete: 422 + if hasattr(operand, "key"): 423 + current_labels.discard(operand.key) 424 + 425 + return current_labels 426 + 427 + 428 + def _apply_labels( 429 + client, 430 + issue_uri: str, 431 + labels: list[str], 432 + repo_labels: list[str], 433 + current_labels: set[str], 434 + ) -> None: 435 + """apply a set of labels to an issue, creating a label op record 436 + 437 + Args: 438 + client: authenticated atproto client 439 + issue_uri: AT-URI of the issue 440 + labels: list of label names or URIs to apply 441 + repo_labels: list of label definition URIs the repo subscribes to 442 + current_labels: set of currently applied label URIs 443 + """ 444 + # resolve label names to URIs 445 + new_label_uris = set() 446 + for label in labels: 447 + if label.startswith("at://"): 448 + new_label_uris.add(label) 449 + else: 450 + for repo_label_uri in repo_labels: 451 + label_name = repo_label_uri.split("/")[-1] 452 + if label_name.lower() == label.lower(): 453 + new_label_uris.add(repo_label_uri) 454 + break 455 + 456 + # calculate diff: what to add and what to delete 457 + labels_to_add = new_label_uris - current_labels 458 + labels_to_delete = current_labels - new_label_uris 459 + 460 + # only create label op if there are changes 461 + if labels_to_add or labels_to_delete: 462 + label_op_tid = int(datetime.now(timezone.utc).timestamp() * 1000000) 463 + label_op_rkey = str(label_op_tid) 464 + 465 + label_op_record = { 466 + "$type": "sh.tangled.label.op", 467 + "subject": issue_uri, 468 + "add": [{"key": label_uri, "value": ""} for label_uri in labels_to_add], 469 + "delete": [ 470 + {"key": label_uri, "value": ""} for label_uri in labels_to_delete 471 + ], 472 + "performedAt": datetime.now(timezone.utc) 473 + .isoformat() 474 + .replace("+00:00", "Z"), 475 + } 476 + 477 + client.com.atproto.repo.put_record( 478 + models.ComAtprotoRepoPutRecord.Data( 479 + repo=client.me.did, 480 + collection="sh.tangled.label.op", 481 + rkey=label_op_rkey, 482 + record=label_op_record, 483 + ) 484 + )
+81 -3
src/tangled_mcp/server.py
··· 88 88 ], 89 89 title: Annotated[str, Field(description="issue title")], 90 90 body: Annotated[str | None, Field(description="issue body/description")] = None, 91 - ) -> dict[str, str]: 91 + labels: Annotated[ 92 + list[str] | None, 93 + Field( 94 + description="optional list of label names (e.g., ['good-first-issue', 'bug']) " 95 + "to apply to the issue" 96 + ), 97 + ] = None, 98 + ) -> dict[str, str | int]: 92 99 """create an issue on a repository 93 100 94 101 Args: 95 102 repo: repository identifier in 'owner/repo' format 96 103 title: issue title 97 104 body: optional issue body/description 105 + labels: optional list of label names to apply 98 106 99 107 Returns: 100 - dict with uri and cid of created issue 108 + dict with uri, cid, and issueId of created issue 101 109 """ 102 110 # resolve owner/repo to (knot, did/repo) 103 111 knot, repo_id = _tangled.resolve_repo_identifier(repo) 104 112 # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 105 - response = _tangled.create_issue(repo_id, title, body) 113 + response = _tangled.create_issue(repo_id, title, body, labels) 114 + return { 115 + "uri": response["uri"], 116 + "cid": response["cid"], 117 + "issueId": response["issueId"], 118 + } 119 + 120 + 121 + @tangled_mcp.tool 122 + def update_repo_issue( 123 + repo: Annotated[ 124 + str, 125 + Field( 126 + description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 127 + ), 128 + ], 129 + issue_id: Annotated[int, Field(description="issue number (e.g., 1, 2, 3...)")], 130 + title: Annotated[str | None, Field(description="new issue title")] = None, 131 + body: Annotated[str | None, Field(description="new issue body/description")] = None, 132 + labels: Annotated[ 133 + list[str] | None, 134 + Field( 135 + description="list of label names to SET (replaces all existing labels). " 136 + "use empty list [] to remove all labels" 137 + ), 138 + ] = None, 139 + ) -> dict[str, str]: 140 + """update an existing issue on a repository 141 + 142 + Args: 143 + repo: repository identifier in 'owner/repo' format 144 + issue_id: issue number to update 145 + title: optional new title (if None, keeps existing) 146 + body: optional new body (if None, keeps existing) 147 + labels: optional list of label names to SET (replaces existing) 148 + 149 + Returns: 150 + dict with uri and cid of updated issue 151 + """ 152 + # resolve owner/repo to (knot, did/repo) 153 + knot, repo_id = _tangled.resolve_repo_identifier(repo) 154 + # update_issue doesn't need knot (uses atproto putRecord, not XRPC) 155 + response = _tangled.update_issue(repo_id, issue_id, title, body, labels) 106 156 return {"uri": response["uri"], "cid": response["cid"]} 157 + 158 + 159 + @tangled_mcp.tool 160 + def delete_repo_issue( 161 + repo: Annotated[ 162 + str, 163 + Field( 164 + description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')" 165 + ), 166 + ], 167 + issue_id: Annotated[ 168 + int, Field(description="issue number to delete (e.g., 1, 2, 3...)") 169 + ], 170 + ) -> dict[str, str]: 171 + """delete an issue from a repository 172 + 173 + Args: 174 + repo: repository identifier in 'owner/repo' format 175 + issue_id: issue number to delete 176 + 177 + Returns: 178 + dict with uri of deleted issue 179 + """ 180 + # resolve owner/repo to (knot, did/repo) 181 + knot, repo_id = _tangled.resolve_repo_identifier(repo) 182 + # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) 183 + response = _tangled.delete_issue(repo_id, issue_id) 184 + return {"uri": response["uri"]} 107 185 108 186 109 187 @tangled_mcp.tool
+6 -2
tests/test_resolver.py
··· 10 10 """test that identifiers without slash are rejected""" 11 11 from tangled_mcp._tangled._client import resolve_repo_identifier 12 12 13 - with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"): 13 + with pytest.raises( 14 + ValueError, match="invalid repo format.*expected 'owner/repo'" 15 + ): 14 16 resolve_repo_identifier("invalid") 15 17 16 18 def test_invalid_format_empty(self): 17 19 """test that empty identifiers are rejected""" 18 20 from tangled_mcp._tangled._client import resolve_repo_identifier 19 21 20 - with pytest.raises(ValueError, match="invalid repo format.*expected 'owner/repo'"): 22 + with pytest.raises( 23 + ValueError, match="invalid repo format.*expected 'owner/repo'" 24 + ): 21 25 resolve_repo_identifier("") 22 26 23 27 def test_valid_format_with_handle(self):
+3 -1
tests/test_server.py
··· 22 22 async with Client(tangled_mcp) as client: 23 23 tools = await client.list_tools() 24 24 25 - assert len(tools) == 3 25 + assert len(tools) == 5 26 26 27 27 tool_names = {tool.name for tool in tools} 28 28 assert "list_repo_branches" in tool_names 29 29 assert "create_repo_issue" in tool_names 30 + assert "update_repo_issue" in tool_names 31 + assert "delete_repo_issue" in tool_names 30 32 assert "list_repo_issues" in tool_names 31 33 32 34 async def test_list_repo_branches_tool_schema(self):