MCP server for tangled

Compare changes

Choose any two refs to compare.

Changed files
+170 -648
.claude
commands
sandbox
src
tangled_mcp
tests
-9
.claude/commands/release.md
··· 1 - check git state and commit and push if appropriate, we are doing to release. 2 - 3 - read @docs/publishing.md and use it to help me cut a new release of tangled-mcp. 4 - 5 - use gh to view the website for the current repo, it should point to the latest published version of the MCP in the official registry, verify after release this points to the new version you just released. 6 - 7 - also curl pypi to verify that the new version is available. 8 - 9 - run a smoke test of the MCP as a top level executable of the library, then follow by a test of the individual tools with the FastMCP client.
-4
CLAUDE.md
··· 22 22 - justfile: `setup`, `test`, `check`, `push` 23 23 - versioning: uv-dynamic-versioning (git tags) 24 24 - type checking: ty + ruff (I, UP) 25 - - remember that `tree` is your friend, better than `ls` and a dream 26 - - **use `jq` for JSON parsing** (not python pipes) 27 - - example: `curl -s https://pypi.org/pypi/tangled-mcp/json | jq -r '.info.version'` 28 - - **never use `sleep`** - poll/check with actual tools instead 29 25 30 26 ## architecture notes 31 27 - repos stored as atproto records in collection `sh.tangled.repo` (NOT `sh.tangled.repo.repo`)
+137
NEXT_STEPS.md
··· 1 + # next steps 2 + 3 + ## critical fixes 4 + 5 + ### 1. label validation must fail loudly 6 + 7 + **problem:** when users specify labels that don't exist in the repo's subscribed label definitions, they're silently ignored. no error, no warning, just nothing happens. 8 + 9 + **current behavior:** 10 + ```python 11 + create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"]) 12 + # -> creates issue with NO labels, returns success 13 + ``` 14 + 15 + **what should happen:** 16 + ```python 17 + create_repo_issue(repo="owner/repo", labels=["demo", "nonexistent"]) 18 + # -> raises ValueError: 19 + # "invalid labels: ['demo', 'nonexistent'] 20 + # available labels for this repo: ['wontfix', 'duplicate', 'good-first-issue', ...]" 21 + ``` 22 + 23 + **fix locations:** 24 + - `src/tangled_mcp/_tangled/_issues.py:_apply_labels()` - validate before applying 25 + - add `validate_labels()` helper that checks against repo's subscribed labels 26 + - fail fast with actionable error message listing available labels 27 + 28 + ### 2. list_repo_issues should include label information 29 + 30 + **problem:** `list_repo_issues` returns issues but doesn't include their labels. labels are stored separately in `sh.tangled.label.op` records and need to be fetched and correlated. 31 + 32 + **impact:** users can't see what labels an issue has without manually querying label ops or checking the UI. 33 + 34 + **fix:** 35 + - add `labels: list[str]` field to `IssueInfo` model 36 + - in `list_repo_issues`, fetch label ops and correlate with issues 37 + - return label names (not URIs) for better UX 38 + 39 + ### 3. fix pydantic field warning 40 + 41 + **warning:** 42 + ``` 43 + UnsupportedFieldAttributeWarning: The 'default' attribute with value None was provided 44 + to the `Field()` function, which has no effect in the context it was used. 45 + ``` 46 + 47 + **likely cause:** somewhere we're using `Field(default=None)` in an `Annotated` type or union context where it doesn't make sense. 48 + 49 + **fix:** audit all `Field()` uses and remove invalid `default=None` declarations. 50 + 51 + ## enhancements 52 + 53 + ### 4. better error messages for repo resolution failures 54 + 55 + when a repo doesn't exist or handle can't be resolved, give users clear next steps: 56 + - is the repo name spelled correctly? 57 + - does the repo exist on tangled.org? 58 + - do you have access to it? 59 + 60 + ### 5. add label listing tool 61 + 62 + users need to know what labels are available for a repo before they can use them. 63 + 64 + **new tool:** 65 + ```python 66 + list_repo_labels(repo: str) -> list[str] 67 + # returns: ["wontfix", "duplicate", "good-first-issue", ...] 68 + ``` 69 + 70 + ### 6. pagination cursor handling 71 + 72 + currently returning raw cursor strings. consider: 73 + - documenting cursor format 74 + - providing helper for "has more pages" checking 75 + - clear examples in docstrings 76 + 77 + ## completed improvements (this session) 78 + 79 + ### โœ… types architecture refactored 80 + - moved from single `types.py` to `types/` directory 81 + - separated concerns: `_common.py`, `_branches.py`, `_issues.py` 82 + - public API in `__init__.py` 83 + - parsing logic moved into types via `.from_api_response()` class methods 84 + 85 + ### โœ… proper validation with annotated types 86 + - `RepoIdentifier = Annotated[str, AfterValidator(normalize_repo_identifier)]` 87 + - strips `@` prefix automatically 88 + - validates format before processing 89 + 90 + ### โœ… clickable URLs instead of AT Protocol internals 91 + - issue operations return `https://tangled.org/@owner/repo/issues/N` 92 + - removed useless `uri` and `cid` from user-facing responses 93 + - URL generation encapsulated in types via `@computed_field` 94 + 95 + ### โœ… proper typing everywhere 96 + - no more `dict[str, Any]` return types 97 + - pydantic models for all results 98 + - type safety throughout 99 + 100 + ### โœ… minimal test coverage 101 + - 17 tests covering public contracts 102 + - no implementation details tested 103 + - validates key behaviors: URL generation, validation, parsing 104 + 105 + ### โœ… demo scripts 106 + - full lifecycle demo 107 + - URL format handling demo 108 + - branch listing demo 109 + - label manipulation demo (revealed silent failure issue) 110 + 111 + ### โœ… documentation improvements 112 + - MCP client installation instructions in collapsible details 113 + - clear usage examples for multiple clients 114 + 115 + ## technical debt 116 + 117 + ### remove unused types 118 + - `RepoInfo`, `PullInfo`, `CreateRepoResult`, `GenericResult` - not used anywhere 119 + - clean up or remove from public API 120 + 121 + ### consolidate URL generation logic 122 + - `_tangled_issue_url()` helper was created to DRY the URL generation 123 + - good pattern, consider extending to other URL types if needed 124 + 125 + ### consider lazy evaluation for expensive validations 126 + - repo resolution happens on every tool call 127 + - could cache repo metadata (knot, did) for duration of connection 128 + - tradeoff: freshness vs performance 129 + 130 + ## priorities 131 + 132 + 1. **critical:** fix label validation (fails silently) 133 + 2. **high:** add labels to list_repo_issues output 134 + 3. **medium:** add list_repo_labels tool 135 + 4. **medium:** fix pydantic warning 136 + 5. **low:** better error messages 137 + 6. **low:** clean up unused types
-617
sandbox/design-pulls.md
··· 1 - # pull requests: design exploration 2 - 3 - **โš ๏ธ CRITICAL: THIS DESIGN CANNOT BE IMPLEMENTED โš ๏ธ** 4 - 5 - **Date discovered**: 2025-10-11 6 - **Reason**: Pull requests in tangled use a fundamentally different architecture than issues. 7 - 8 - ## Why PR support via atproto records doesn't work 9 - 10 - After implementing this design and testing, we discovered that **tangled's appview does not ingest pull records from the firehose**. Here's the architectural difference: 11 - 12 - ### Issues (โœ… Works): 13 - 1. **Jetstream subscription**: `appview/state/state.go:109` subscribes to `sh.tangled.repo.issue` 14 - 2. **Firehose ingester**: `appview/ingester.go:79-80` has `ingestIssue()` function 15 - 3. **Pattern**: atproto record is source of truth โ†’ firehose keeps database synchronized 16 - 4. **Result**: MCP tools work! Create issue via atproto โ†’ appears on tangled.org 17 - 18 - ### Pulls (โŒ Broken): 19 - 1. **No jetstream subscription**: `tangled.RepoPullNSID` is **missing** from subscription list 20 - 2. **No firehose ingester**: No `ingestPull()` function exists in `appview/ingester.go` 21 - 3. **Pattern**: Database is source of truth โ†’ atproto record is decorative 22 - 4. **Web UI flow** (`appview/pulls/pulls.go:1196`): 23 - - Creates DB entry FIRST via `db.NewPull()` 24 - - THEN creates atproto record as "announcement" 25 - 5. **Result**: MCP-created PRs are **orphan records** that exist on PDS but never appear on tangled.org 26 - 27 - ### Why this design exists 28 - 29 - Looking at the code (`appview/pulls/pulls.go`), pulls have: 30 - - **Submissions array**: Multiple rounds of patches (DB-only concept) 31 - - **Size concerns**: Patches can be megabytes (expensive in atproto) 32 - - **Complexity**: Appview manages pull state in its database with features not in atproto schema 33 - - **Pragmatism**: They wanted to ship pulls quickly, made DB the source of truth 34 - 35 - ### Evidence 36 - 37 - ```bash 38 - # Issues are subscribed in jetstream 39 - grep -n "RepoIssueNSID" sandbox/tangled-core/appview/state/state.go 40 - # 109: tangled.RepoIssueNSID, 41 - 42 - # Pulls are NOT subscribed 43 - grep -n "RepoPullNSID" sandbox/tangled-core/appview/state/state.go 44 - # (no results) 45 - 46 - # Issues have an ingester 47 - grep -A 2 "RepoIssueNSID:" sandbox/tangled-core/appview/ingester.go 48 - # case tangled.RepoIssueNSID: 49 - # err = i.ingestIssue(ctx, e) 50 - 51 - # Pulls do NOT have an ingester 52 - grep "RepoPullNSID:" sandbox/tangled-core/appview/ingester.go 53 - # (no results) 54 - ``` 55 - 56 - ### Conclusion 57 - 58 - **Pull request support cannot be added to tangled-mcp** without changes to tangled-core (adding firehose consumer). The design below is architecturally sound but incompatible with tangled's current implementation. 59 - 60 - --- 61 - 62 - ## design principle: gh CLI parity 63 - 64 - **goal**: tangled-mcp pull request tools should be a subset of `gh pr` commands with matching semantics 65 - 66 - - users familiar with `gh` should feel at home 67 - - parameters should match where possible 68 - - we implement what tangled's atproto schema supports 69 - - we don't try to exceed gh's surface area 70 - 71 - ## gh pr commands (reference) 72 - 73 - ### general commands (gh) 74 - - `gh pr create` - create a PR with title, body, labels, draft state 75 - - `gh pr list` - list PRs with filters (state, labels, author, base, head) 76 - - `gh pr view` - show details of a single PR 77 - - `gh pr close` - close a PR 78 - - `gh pr reopen` - reopen a closed PR 79 - - `gh pr edit` - edit title, body, labels, base branch 80 - - `gh pr merge` - merge a PR 81 - 82 - ### what we can support (tangled MCP) 83 - 84 - | gh command | tangled tool | notes | 85 - |------------|--------------|-------| 86 - | `gh pr create` | `create_repo_pull` | โœ… title, body, base, head, labels, draft | 87 - | `gh pr list` | `list_repo_pulls` | โœ… state, labels, limit filtering | 88 - | `gh pr view` | `get_repo_pull` | โœ… full details of one PR | 89 - | `gh pr close` | `close_repo_pull` | โœ… via status update | 90 - | `gh pr reopen` | `reopen_repo_pull` | โœ… via status update | 91 - | `gh pr edit` | `update_repo_pull` | โœ… title, body, labels | 92 - | `gh pr merge` | `merge_repo_pull` | โœ… via status update (logical, not git merge) | 93 - | `gh pr comment` | โŒ not v1 | need `sh.tangled.repo.pull.comment` support | 94 - | `gh pr diff` | โŒ not v1 | could show `patch` field | 95 - | `gh pr checks` | โŒ not supported | no CI concept in tangled | 96 - | `gh pr review` | โŒ not v1 | need review records | 97 - 98 - ## current state 99 - 100 - ### what we have (issues) 101 - - **collection**: `sh.tangled.repo.issue` (stored on user's PDS) 102 - - **extra fields**: `issueId` (sequential), `owner` (creator DID) 103 - - **labels**: separate `sh.tangled.label.op` records, applied/removed via ops 104 - - **operations**: create, update, delete, list, list_labels 105 - - **no state tracking**: we don't use `sh.tangled.repo.issue.status` yet 106 - - **no comments**: we don't use `sh.tangled.repo.issue.comment` yet 107 - 108 - ### what we have (branches) 109 - - **knot XRPC**: `sh.tangled.repo.listBranches` query via knot 110 - - **read-only**: no branch creation/deletion yet 111 - - **operations**: list only 112 - 113 - ## pull request schema (from lexicons) 114 - 115 - ### core record: `sh.tangled.repo.pull` 116 - ```typescript 117 - { 118 - target: { 119 - repo: string (at-uri), // where it's merging to 120 - branch: string 121 - }, 122 - source: { 123 - branch: string, // where it's coming from 124 - sha: string (40 chars), // commit hash 125 - repo?: string (at-uri) // optional: for cross-repo pulls 126 - }, 127 - title: string, 128 - body?: string, 129 - patch: string, // git diff format 130 - createdAt: datetime 131 - } 132 - ``` 133 - 134 - ### state tracking: `sh.tangled.repo.pull.status` 135 - ```typescript 136 - { 137 - pull: string (at-uri), // reference to pull record 138 - status: "open" | "closed" | "merged" 139 - } 140 - ``` 141 - 142 - ### comments: `sh.tangled.repo.pull.comment` 143 - ```typescript 144 - { 145 - pull: string (at-uri), 146 - body: string, 147 - createdAt: datetime 148 - } 149 - ``` 150 - 151 - ## design questions 152 - 153 - ### 1. sequential IDs (pullId) 154 - **question**: should pulls have `pullId` like issues have `issueId`? 155 - 156 - **considerations**: 157 - - human-friendly references: "PR #42" vs AT-URI 158 - - need to maintain counter per-repo (same pattern as issues) 159 - - easier for users to reference in comments/descriptions 160 - - tangled.org URLs probably expect this: `tangled.org/@owner/repo/pulls/42` 161 - 162 - **recommendation**: yes, add `pullId` field following issue pattern 163 - 164 - ### 2. resources vs tools 165 - **current**: 1 resource (`tangled://status`), 6 tools 166 - 167 - **question**: should we expose repos/issues/pulls as MCP resources? 168 - 169 - **resources are for**: 170 - - read-only data 171 - - things that change over time 172 - - content the LLM should "know about" contextually 173 - - example: `tangled://repo/{owner}/{repo}/issues` โ†’ feed LLM current issues 174 - 175 - **tools are for**: 176 - - actions (create, update, delete) 177 - - queries with parameters 178 - - returning specific structured data 179 - 180 - **potential resources**: 181 - - `tangled://repo/{owner}/{repo}` โ†’ repo metadata, default branch, description 182 - - `tangled://repo/{owner}/{repo}/issues` โ†’ current open issues 183 - - `tangled://repo/{owner}/{repo}/pulls` โ†’ current open pulls 184 - - `tangled://repo/{owner}/{repo}/branches` โ†’ all branches 185 - 186 - **recommendation**: 187 - - keep tools for actions (create/update/delete) 188 - - add resources for "current state" views 189 - - resources update when queried (not live/streaming) 190 - 191 - ### 3. patch generation 192 - **question**: where does the `patch` field come from? 193 - 194 - **options**: 195 - a) **client generates**: user provides git diff output 196 - - pros: simple, no server-side git ops 197 - - cons: user burden, error-prone 198 - 199 - b) **knot XRPC**: new endpoint `sh.tangled.repo.generatePatch` 200 - - pros: server generates correct diff 201 - - cons: requires knot changes 202 - 203 - c) **hybrid**: accept both user-provided patch OR branch refs 204 - - if patch provided: use it 205 - - if only branches: call knot to generate 206 - - pros: flexible 207 - - cons: more complex 208 - 209 - **recommendation**: start with (a), add (b) later as knot capability 210 - 211 - ### 4. cross-repo pulls (forks) 212 - **question**: how to handle `source.repo` different from `target.repo`? 213 - 214 - **use case**: fork workflow 215 - - user forks `owner-a/repo` to `owner-b/repo` 216 - - makes changes on fork 217 - - opens pull from `owner-b/repo:feature` โ†’ `owner-a/repo:main` 218 - 219 - **challenges**: 220 - - need to resolve both repos (source and target) 221 - - patch generation across repos 222 - - permissions: who can create pulls? 223 - 224 - **recommendation**: 225 - - v1: same-repo pulls only (`source.repo` optional, defaults to target) 226 - - v2: add cross-repo support once we understand patterns 227 - 228 - ### 5. state management 229 - **question**: do we track state separately or in-record? 230 - 231 - **issues**: currently don't use `sh.tangled.repo.issue.status` 232 - **pulls**: lexicon has `sh.tangled.repo.pull.status` 233 - 234 - **pattern from labels**: 235 - - labels are separate ops 236 - - ops can be applied/reverted 237 - - current state = sum of all ops 238 - 239 - **state is simpler**: 240 - - open โ†’ closed โ†’ merged (mostly linear) 241 - - probably doesn't need full ops history 242 - - could just update a field on pull record OR use status records 243 - 244 - **recommendation**: 245 - - use `sh.tangled.repo.pull.status` records (follow lexicon) 246 - - easier to track state changes over time 247 - - consistent with label pattern 248 - - can query "all status changes for pull X" 249 - 250 - **draft state**: gh treats draft as a separate boolean, but tangled's lexicon shows: 251 - - `sh.tangled.repo.pull.status.open` 252 - - `sh.tangled.repo.pull.status.closed` 253 - - `sh.tangled.repo.pull.status.merged` 254 - 255 - **solution**: we could: 256 - - (a) add custom `draft` field to pull record (not in lexicon, might break) 257 - - (b) treat draft as metadata in status record 258 - - (c) add `sh.tangled.repo.pull.status.draft` as custom state 259 - 260 - **recommendation**: (c) - add draft as a custom state value 261 - - fits existing pattern 262 - - `draft` โ†’ `open` transition when ready 263 - - backwards compatible (lexicon uses `knownValues` not enum) 264 - 265 - ### 6. interconnections 266 - **entities that reference each other**: 267 - - issues mention pulls: "closes #42" 268 - - pulls mention issues: "fixes #123" 269 - - both have labels, comments 270 - - pulls reference commits/branches 271 - 272 - **question**: how to expose these relationships? 273 - 274 - **options**: 275 - a) **inline**: include referenced entities in responses 276 - - `list_repo_pulls` returns issues it closes 277 - - bloats responses 278 - 279 - b) **separate queries**: tools to fetch relationships 280 - - `get_pull_related_issues(pull_id)` 281 - - `get_issue_related_pulls(issue_id)` 282 - - more API calls but cleaner 283 - 284 - c) **resources**: expose as graphs 285 - - `tangled://repo/{owner}/{repo}/graph` โ†’ all entities + edges 286 - - LLM can traverse 287 - - ambitious 288 - 289 - **recommendation**: start with (b), consider (c) as resources mature 290 - 291 - ### 7. comments 292 - **question**: support issue/pull comments now or later? 293 - 294 - **considerations**: 295 - - both have `comment` collections 296 - - valuable for context (PR review discussions) 297 - - adds complexity (list, create, update, delete comments) 298 - 299 - **recommendation**: 300 - - v1: skip comments, focus on core pull CRUD 301 - - v2: add comments once pull basics work 302 - - keeps initial scope manageable 303 - 304 - ## tool signatures (gh-style) 305 - 306 - ### create_repo_pull 307 - ```python 308 - def create_repo_pull( 309 - repo: str, # gh: -R, --repo 310 - title: str, # gh: -t, --title 311 - body: str | None = None, # gh: -b, --body 312 - base: str = "main", # gh: -B, --base (target branch) 313 - head: str, # gh: -H, --head (source branch) 314 - source_sha: str, # commit hash (required, no gh equiv - we need it for atproto) 315 - patch: str, # git diff (required, no gh equiv - atproto schema) 316 - labels: list[str] | None = None, # gh: -l, --label 317 - draft: bool = False, # gh: -d, --draft 318 - ) -> CreatePullResult: 319 - """ 320 - create a pull request 321 - 322 - similar to: gh pr create --title "..." --body "..." --base main --head feature --label bug --draft 323 - """ 324 - ``` 325 - 326 - ### update_repo_pull 327 - ```python 328 - def update_repo_pull( 329 - repo: str, # gh: -R, --repo 330 - pull_id: int, # gh: <number> 331 - title: str | None = None, # gh: -t, --title 332 - body: str | None = None, # gh: -b, --body 333 - base: str | None = None, # gh: -B, --base (change target branch) 334 - add_labels: list[str] | None = None, # gh: --add-label 335 - remove_labels: list[str] | None = None, # gh: --remove-label 336 - ) -> UpdatePullResult: 337 - """ 338 - edit a pull request 339 - 340 - similar to: gh pr edit 42 --title "..." --add-label bug --remove-label wontfix 341 - """ 342 - ``` 343 - 344 - ### list_repo_pulls 345 - ```python 346 - def list_repo_pulls( 347 - repo: str, # gh: -R, --repo 348 - state: str = "open", # gh: -s, --state {open|closed|merged|all} 349 - labels: list[str] | None = None, # gh: -l, --label 350 - base: str | None = None, # gh: -B, --base (filter by target branch) 351 - head: str | None = None, # gh: -H, --head (filter by source branch) 352 - draft: bool | None = None, # gh: -d, --draft (filter by draft state) 353 - limit: int = 30, # gh: -L, --limit (default 30) 354 - cursor: str | None = None, # pagination 355 - ) -> ListPullsResult: 356 - """ 357 - list pull requests 358 - 359 - similar to: gh pr list --state open --label bug --limit 50 360 - """ 361 - ``` 362 - 363 - ### get_repo_pull 364 - ```python 365 - def get_repo_pull( 366 - repo: str, # gh: -R, --repo 367 - pull_id: int, # gh: <number> 368 - ) -> PullInfo: 369 - """ 370 - view a pull request 371 - 372 - similar to: gh pr view 42 373 - """ 374 - ``` 375 - 376 - ### close_repo_pull 377 - ```python 378 - def close_repo_pull( 379 - repo: str, # gh: -R, --repo 380 - pull_id: int, # gh: <number> 381 - ) -> UpdatePullResult: 382 - """ 383 - close a pull request (sets status to closed) 384 - 385 - similar to: gh pr close 42 386 - """ 387 - ``` 388 - 389 - ### reopen_repo_pull 390 - ```python 391 - def reopen_repo_pull( 392 - repo: str, # gh: -R, --repo 393 - pull_id: int, # gh: <number> 394 - ) -> UpdatePullResult: 395 - """ 396 - reopen a closed pull request (sets status back to open) 397 - 398 - similar to: gh pr reopen 42 399 - """ 400 - ``` 401 - 402 - ### merge_repo_pull 403 - ```python 404 - def merge_repo_pull( 405 - repo: str, # gh: -R, --repo 406 - pull_id: int, # gh: <number> 407 - ) -> UpdatePullResult: 408 - """ 409 - mark a pull request as merged (sets status to merged) 410 - 411 - note: this is a logical merge (status change), not an actual git merge 412 - similar to: gh pr merge 42 (but without the git operation) 413 - """ 414 - ``` 415 - 416 - ## proposed roadmap 417 - 418 - ### phase 1: core pr operations (gh parity) 419 - 420 - **7 tools matching gh pr commands**: 421 - 422 - 1. **create_repo_pull** (matches `gh pr create`) 423 - - parameters: repo, title, body, base, head, source_sha, patch, labels, draft 424 - - generates pullId (like issueId) 425 - - creates `sh.tangled.repo.pull` record 426 - - creates initial `sh.tangled.repo.pull.status` record (open or draft) 427 - - applies labels if provided 428 - - returns CreatePullResult with pullId and URL 429 - 430 - 2. **update_repo_pull** (matches `gh pr edit`) 431 - - parameters: repo, pull_id, title, body, base, add_labels, remove_labels 432 - - updates pull record via putRecord + swap 433 - - handles incremental label changes (add/remove pattern) 434 - - returns UpdatePullResult with pullId and URL 435 - 436 - 3. **list_repo_pulls** (matches `gh pr list`) 437 - - parameters: repo, state, labels, base, head, draft, limit, cursor 438 - - queries `sh.tangled.repo.pull` + correlates with status 439 - - filters by state (open/closed/merged/all), labels, branches, draft 440 - - default limit 30 (matching gh) 441 - - includes labels for each pull 442 - - returns ListPullsResult with pulls and cursor 443 - 444 - 4. **get_repo_pull** (matches `gh pr view`) 445 - - parameters: repo, pull_id 446 - - fetches single pull with full details (target, source, patch, status, labels) 447 - - returns PullInfo model 448 - 449 - 5. **close_repo_pull** (matches `gh pr close`) 450 - - parameters: repo, pull_id 451 - - creates new status record with "closed" 452 - - returns UpdatePullResult 453 - 454 - 6. **reopen_repo_pull** (matches `gh pr reopen`) 455 - - parameters: repo, pull_id 456 - - creates new status record with "open" 457 - - only works if current status is "closed" 458 - - returns UpdatePullResult 459 - 460 - 7. **merge_repo_pull** (matches `gh pr merge` logically) 461 - - parameters: repo, pull_id 462 - - creates new status record with "merged" 463 - - note: logical merge only (no git operation) 464 - - returns UpdatePullResult 465 - 466 - **types** (following issue pattern): 467 - - `PullInfo` model (uri, cid, pullId, title, body, target, source, createdAt, labels, status) 468 - - `CreatePullResult` (repo, pull_id, url) 469 - - `UpdatePullResult` (repo, pull_id, url) 470 - - `ListPullsResult` (pulls, cursor) 471 - 472 - **new module**: 473 - - `src/tangled_mcp/_tangled/_pulls.py` (parallel to _issues.py) 474 - - `src/tangled_mcp/types/_pulls.py` (parallel to _issues.py) 475 - 476 - ### phase 2: labels + better state 477 - **enhancements**: 478 - - pulls support labels (reuse `_validate_labels`, `_apply_labels`) 479 - - `list_pull_status_history(repo, pull_id)` โ†’ all status changes 480 - - pull status in URL: `tangled.org/@owner/repo/pulls/42` shows status 481 - 482 - ### phase 3: resources 483 - **add resources**: 484 - - `tangled://repo/{owner}/{repo}/pulls/open` โ†’ current open PRs 485 - - `tangled://repo/{owner}/{repo}/pulls/{pull_id}` โ†’ specific pull context 486 - - helps LLM understand "what PRs exist for this repo?" 487 - 488 - ### phase 4: cross-repo + comments 489 - **ambitious**: 490 - - cross-repo pull support (forks) 491 - - comment creation/listing 492 - - patch generation via knot XRPC 493 - 494 - ## open questions 495 - 496 - ### 1. draft state implementation 497 - **question**: should we use `sh.tangled.repo.pull.status.draft` as a custom state? 498 - - **option a**: separate draft field on pull record (not in lexicon) 499 - - **option b**: draft as custom status value `sh.tangled.repo.pull.status.draft` 500 - - **recommended**: option b - fits lexicon pattern, `draft` โ†’ `open` transition 501 - 502 - ### 2. patch format (v1 scope) 503 - **question**: how do users provide the patch field? 504 - - **v1**: user provides git diff string (simple, no server dependency) 505 - - **v2**: could add knot XRPC to generate from branch refs 506 - - **gh equivalent**: `gh pr create` auto-generates from local branch 507 - 508 - ### 3. ready-for-review transition 509 - **question**: `gh pr ready` marks draft PR as ready - how to support? 510 - - **option a**: separate `ready_repo_pull(repo, pull_id)` tool 511 - - **option b**: use `update_repo_pull` with status change 512 - - **recommended**: option a - matches gh command structure 513 - 514 - ### 4. URL format confirmation 515 - **assumption**: `https://tangled.org/@owner/repo/pulls/42` 516 - - matches issue pattern 517 - - need to confirm with tangled.org routing 518 - 519 - ### 5. merge semantics 520 - **clarification**: `merge_repo_pull` is logical only (status change) 521 - - does NOT perform git merge operation 522 - - tangled may handle actual merge separately 523 - - gh does both (status + git operation) 524 - 525 - ## implementation notes 526 - 527 - ### pattern consistency with issues 528 - ```python 529 - # issues pattern (working) 530 - def create_issue(repo_id, title, body, labels): 531 - # 1. resolve repo to AT-URI 532 - # 2. find next issueId 533 - # 3. create record with tid rkey 534 - # 4. apply labels if provided 535 - # 5. return {uri, cid, issueId} 536 - 537 - # pulls pattern (proposed) 538 - def create_pull(repo_id, target_branch, source_branch, source_sha, title, body, patch, labels): 539 - # 1. resolve repo to AT-URI 540 - # 2. find next pullId 541 - # 3. create pull record with {target, source, patch, ...} 542 - # 4. create status record (open) 543 - # 5. apply labels if provided 544 - # 6. return {uri, cid, pullId} 545 - ``` 546 - 547 - ### state tracking pattern 548 - ```python 549 - def _get_current_pull_status(client, pull_uri): 550 - """get latest status for a pull by querying status records""" 551 - status_records = client.com.atproto.repo.list_records( 552 - collection="sh.tangled.repo.pull.status", 553 - repo=client.me.did, 554 - ) 555 - 556 - # find all status records for this pull 557 - pull_statuses = [ 558 - r for r in status_records.records 559 - if getattr(r.value, "pull", None) == pull_uri 560 - ] 561 - 562 - # return most recent (last created) 563 - if pull_statuses: 564 - latest = max(pull_statuses, key=lambda r: getattr(r.value, "createdAt", "")) 565 - return getattr(latest.value, "status", "open") 566 - 567 - return "open" # default 568 - ``` 569 - 570 - ### label reuse 571 - ```python 572 - # labels work the same for issues and pulls 573 - # _apply_labels takes a subject_uri (issue or pull) 574 - _apply_labels(client, pull_uri, labels, repo_labels, current_labels) 575 - 576 - # so label ops are generic: 577 - { 578 - "$type": "sh.tangled.label.op", 579 - "subject": "at://did/sh.tangled.repo.pull/abc123", # or issue URI 580 - "add": [...], 581 - "delete": [...] 582 - } 583 - ``` 584 - 585 - ## summary: gh pr โ†’ tangled MCP mapping 586 - 587 - ### v1 feature matrix (phase 1) 588 - 589 - | feature | gh command | tangled tool | parameters | notes | 590 - |---------|-----------|--------------|------------|-------| 591 - | create PR | `gh pr create` | `create_repo_pull` | title, body, base, head, source_sha, patch, labels, draft | โœ… full parity except auto-patch | 592 - | edit PR | `gh pr edit` | `update_repo_pull` | title, body, base, add_labels, remove_labels | โœ… full parity | 593 - | list PRs | `gh pr list` | `list_repo_pulls` | state, labels, base, head, draft, limit | โœ… full parity (default limit 30) | 594 - | view PR | `gh pr view` | `get_repo_pull` | pull_id | โœ… full parity | 595 - | close PR | `gh pr close` | `close_repo_pull` | pull_id | โœ… status change only | 596 - | reopen PR | `gh pr reopen` | `reopen_repo_pull` | pull_id | โœ… status change only | 597 - | merge PR | `gh pr merge` | `merge_repo_pull` | pull_id | โš ๏ธ logical only (no git merge) | 598 - | mark ready | `gh pr ready` | `ready_repo_pull` | pull_id | โœ… draft โ†’ open transition | 599 - 600 - ### not in v1 (future) 601 - - `gh pr comment` โ†’ need `sh.tangled.repo.pull.comment` support 602 - - `gh pr diff` โ†’ could show `patch` field 603 - - `gh pr review` โ†’ need review records 604 - - `gh pr checks` โ†’ no CI concept in tangled 605 - 606 - ### key differences from gh 607 - 1. **patch field**: users provide git diff string (gh auto-generates) 608 - 2. **merge**: logical status change only (gh performs git merge) 609 - 3. **source_sha**: required parameter (gh infers from branch) 610 - 4. **pullId**: explicit numeric ID (gh uses number or branch name) 611 - 612 - ### preserved gh patterns 613 - - parameter names match gh flags where possible 614 - - state values: `open`, `closed`, `merged`, `draft` 615 - - filtering: state, labels, base, head, draft 616 - - default limit: 30 (matching gh pr list) 617 - - incremental label updates: add/remove pattern
+2 -2
server.json
··· 3 3 "name": "io.github.zzstoatzz/tangled-mcp", 4 4 "title": "Tangled MCP", 5 5 "description": "MCP server for Tangled git platform. Manage repositories, branches, and issues on tangled.org.", 6 - "version": "0.0.10", 6 + "version": "0.0.9", 7 7 "packages": [ 8 8 { 9 9 "registryType": "pypi", 10 10 "identifier": "tangled-mcp", 11 - "version": "0.0.10", 11 + "version": "0.0.9", 12 12 "transport": { 13 13 "type": "stdio" 14 14 }
+1 -8
src/tangled_mcp/_tangled/_issues.py
··· 79 79 80 80 next_issue_id = max_issue_id + 1 81 81 82 - # validate labels BEFORE creating the issue to prevent orphaned issues 83 - if labels: 84 - _validate_labels(labels, repo_labels) 85 - 86 82 # generate timestamp ID for rkey 87 83 tid = int(datetime.now(timezone.utc).timestamp() * 1000000) 88 84 rkey = str(tid) ··· 412 408 # build map of issue_uri -> current label URIs 413 409 issue_labels_map: dict[str, set[str]] = {uri: set() for uri in issue_uris} 414 410 for op_record in label_ops.records: 415 - if ( 416 - hasattr(op_record.value, "subject") 417 - and op_record.value.subject in issue_labels_map 418 - ): 411 + if hasattr(op_record.value, "subject") and op_record.value.subject in issue_labels_map: 419 412 subject_uri = op_record.value.subject 420 413 if hasattr(op_record.value, "add"): 421 414 for operand in op_record.value.add:
+8 -4
src/tangled_mcp/server.py
··· 53 53 limit: Annotated[ 54 54 int, Field(ge=1, le=100, description="maximum number of branches to return") 55 55 ] = 50, 56 + cursor: Annotated[str | None, Field(description="pagination cursor")] = None, 56 57 ) -> ListBranchesResult: 57 58 """list branches for a repository 58 59 59 60 Args: 60 61 repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp') 61 62 limit: maximum number of branches to return (1-100) 63 + cursor: optional pagination cursor 62 64 63 65 Returns: 64 - list of branches 66 + list of branches with optional cursor for pagination 65 67 """ 66 68 # resolve owner/repo to (knot, did/repo) 67 69 knot, repo_id = _tangled.resolve_repo_identifier(repo) 68 - response = _tangled.list_branches(knot, repo_id, limit, cursor=None) 70 + response = _tangled.list_branches(knot, repo_id, limit, cursor) 69 71 70 72 return ListBranchesResult.from_api_response(response) 71 73 ··· 186 188 limit: Annotated[ 187 189 int, Field(ge=1, le=100, description="maximum number of issues to return") 188 190 ] = 20, 191 + cursor: Annotated[str | None, Field(description="pagination cursor")] = None, 189 192 ) -> ListIssuesResult: 190 193 """list issues for a repository 191 194 192 195 Args: 193 196 repo: repository identifier in 'owner/repo' format 194 197 limit: maximum number of issues to return (1-100) 198 + cursor: optional pagination cursor 195 199 196 200 Returns: 197 - ListIssuesResult with list of issues 201 + ListIssuesResult with list of issues and optional cursor 198 202 """ 199 203 # resolve owner/repo to (knot, did/repo) 200 204 _, repo_id = _tangled.resolve_repo_identifier(repo) 201 205 # list_repo_issues doesn't need knot (queries atproto records, not XRPC) 202 - response = _tangled.list_repo_issues(repo_id, limit, cursor=None) 206 + response = _tangled.list_repo_issues(repo_id, limit, cursor) 203 207 204 208 return ListIssuesResult.from_api_response(response) 205 209
+4 -2
src/tangled_mcp/types/_branches.py
··· 16 16 """result of listing branches""" 17 17 18 18 branches: list[BranchInfo] 19 + cursor: str | None = None 19 20 20 21 @classmethod 21 22 def from_api_response(cls, response: dict[str, Any]) -> "ListBranchesResult": ··· 27 28 "branches": [ 28 29 {"reference": {"name": "main", "hash": "abc123"}}, 29 30 ... 30 - ] 31 + ], 32 + "cursor": "optional_cursor" 31 33 } 32 34 33 35 Returns: ··· 44 46 ) 45 47 ) 46 48 47 - return cls(branches=branches) 49 + return cls(branches=branches, cursor=response.get("cursor"))
+4 -2
src/tangled_mcp/types/_issues.py
··· 61 61 """result of listing issues""" 62 62 63 63 issues: list[IssueInfo] 64 + cursor: str | None = None 64 65 65 66 @classmethod 66 67 def from_api_response(cls, response: dict[str, Any]) -> "ListIssuesResult": ··· 79 80 "createdAt": "..." 80 81 }, 81 82 ... 82 - ] 83 + ], 84 + "cursor": "optional_cursor" 83 85 } 84 86 85 87 Returns: 86 88 ListIssuesResult with parsed issues 87 89 """ 88 90 issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] 89 - return cls(issues=issues) 91 + return cls(issues=issues, cursor=response.get("cursor"))
+2
tests/test_server.py
··· 56 56 assert properties["limit"]["maximum"] == 100 57 57 assert properties["limit"]["default"] == 50 58 58 59 + assert "cursor" in properties 60 + 59 61 # required parameters 60 62 assert tool.inputSchema["required"] == ["repo"]
+12
tests/test_types.py
··· 58 58 {"reference": {"name": "main", "hash": "abc123"}}, 59 59 {"reference": {"name": "dev", "hash": "def456"}}, 60 60 ], 61 + "cursor": "next_page", 61 62 } 62 63 63 64 result = ListBranchesResult.from_api_response(response) ··· 67 68 assert result.branches[0].sha == "abc123" 68 69 assert result.branches[1].name == "dev" 69 70 assert result.branches[1].sha == "def456" 71 + assert result.cursor == "next_page" 72 + 73 + def test_handles_missing_cursor(self): 74 + """cursor is optional in API response""" 75 + response = {"branches": [{"reference": {"name": "main", "hash": "abc123"}}]} 76 + 77 + result = ListBranchesResult.from_api_response(response) 78 + 79 + assert len(result.branches) == 1 80 + assert result.cursor is None 70 81 71 82 def test_handles_empty_branches(self): 72 83 """handles empty branches list""" ··· 75 86 result = ListBranchesResult.from_api_response(response) 76 87 77 88 assert result.branches == [] 89 + assert result.cursor is None