MCP server for tangled

Compare changes

Choose any two refs to compare.

-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.
+1 -1
.github/workflows/publish-mcp.yml
··· 36 36 37 37 - name: install mcp publisher 38 38 run: | 39 - curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.3/mcp-publisher_linux_amd64.tar.gz" | tar xz 39 + curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.2.3/mcp-publisher_1.2.3_linux_amd64.tar.gz" | tar xz 40 40 sudo mv mcp-publisher /usr/local/bin/ 41 41 42 42 - name: login to mcp registry (github oidc)
-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
+1 -4
README.md
··· 102 102 - `create_repo_issue(repo, title, body, labels)` - create an issue with optional labels 103 103 - `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels 104 104 - `delete_repo_issue(repo, issue_id)` - delete an issue 105 - - `list_repo_issues(repo, limit)` - list issues for a repository 105 + - `list_repo_issues(repo, limit, cursor)` - list issues for a repository 106 106 - `list_repo_labels(repo)` - list available labels for a repository 107 - 108 - ### pull requests 109 - - `list_repo_pulls(repo, limit)` - list PRs targeting a repository (only shows PRs you created) 110 107 111 108 ## development 112 109
-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
+3 -3
server.json
··· 1 1 { 2 - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", 2 + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.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.14", 6 + "version": "0.0.9", 7 7 "packages": [ 8 8 { 9 9 "registryType": "pypi", 10 10 "identifier": "tangled-mcp", 11 - "version": "0.0.14", 11 + "version": "0.0.9", 12 12 "transport": { 13 13 "type": "stdio" 14 14 }
-2
src/tangled_mcp/_tangled/__init__.py
··· 13 13 list_repo_labels, 14 14 update_issue, 15 15 ) 16 - from tangled_mcp._tangled._pulls import list_repo_pulls 17 16 18 17 __all__ = [ 19 18 "_get_authenticated_client", ··· 24 23 "delete_issue", 25 24 "list_repo_issues", 26 25 "list_repo_labels", 27 - "list_repo_pulls", 28 26 "resolve_repo_identifier", 29 27 ]
+7 -25
src/tangled_mcp/_tangled/_client.py
··· 8 8 from tangled_mcp.settings import TANGLED_DID, settings 9 9 10 10 11 - def _extract_error_message(e: Exception) -> str: 12 - """extract a clean, concise error message from an exception""" 13 - # handle atproto RequestErrorBase (BadRequestError, UnauthorizedError, etc.) 14 - # structure: e.response.content.message 15 - if hasattr(e, "response") and e.response: 16 - content = getattr(e.response, "content", None) 17 - if content and hasattr(content, "message"): 18 - return content.message 19 - # handle httpx errors 20 - if hasattr(e, "response") and hasattr(e.response, "text"): 21 - return e.response.text[:200] 22 - # fallback to string but limit length 23 - msg = str(e) 24 - if len(msg) > 200: 25 - return msg[:200] + "..." 26 - return msg 27 - 28 - 29 11 def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]: 30 12 """resolve owner/repo format to (knot, did/repo) for tangled XRPC 31 13 ··· 62 44 ) 63 45 owner_did = response.did 64 46 except Exception as e: 65 - # extract clean error message 66 - msg = _extract_error_message(e) 67 - raise ValueError(f"failed to resolve handle '{owner}': {msg}") from e 47 + raise ValueError(f"failed to resolve handle '{owner}': {e}") from e 68 48 69 49 # query owner's repo collection to find repo and get knot 70 50 try: ··· 76 56 ) 77 57 ) 78 58 except Exception as e: 79 - msg = _extract_error_message(e) 80 - raise ValueError(f"failed to list repos for '{owner}': {msg}") from e 59 + raise ValueError(f"failed to list repos for '{owner}': {e}") from e 81 60 82 61 # find repo with matching name and extract knot 83 62 for record in records.records: ··· 107 86 try: 108 87 client.login(settings.tangled_handle, settings.tangled_password) 109 88 except Exception as e: 110 - msg = _extract_error_message(e) 111 - raise RuntimeError(f"auth failed for '{settings.tangled_handle}': {msg}") from e 89 + raise RuntimeError( 90 + f"failed to authenticate with handle '{settings.tangled_handle}'. " 91 + f"verify TANGLED_HANDLE and TANGLED_PASSWORD are correct. " 92 + f"error: {e}" 93 + ) from e 112 94 113 95 return client 114 96
+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:
-92
src/tangled_mcp/_tangled/_pulls.py
··· 1 - """pull request operations for tangled""" 2 - 3 - from typing import Any 4 - 5 - from atproto import models 6 - 7 - from tangled_mcp._tangled._client import _get_authenticated_client 8 - 9 - 10 - def list_repo_pulls(repo_id: str, limit: int = 50) -> dict[str, Any]: 11 - """list pull requests created by the authenticated user for a repository 12 - 13 - note: this only returns PRs that the authenticated user created. 14 - tangled stores PRs in the creator's repo, so we can only see our own PRs. 15 - 16 - Args: 17 - repo_id: repository identifier in "did/repo" format 18 - limit: maximum number of pulls to return 19 - 20 - Returns: 21 - dict containing pulls list 22 - """ 23 - client = _get_authenticated_client() 24 - 25 - if not client.me: 26 - raise RuntimeError("client not authenticated") 27 - 28 - # parse repo_id to get owner_did and repo_name 29 - if "/" not in repo_id: 30 - raise ValueError(f"invalid repo_id format: {repo_id}") 31 - 32 - owner_did, repo_name = repo_id.split("/", 1) 33 - 34 - # get the repo AT-URI by querying the repo collection 35 - records = client.com.atproto.repo.list_records( 36 - models.ComAtprotoRepoListRecords.Params( 37 - repo=owner_did, 38 - collection="sh.tangled.repo", 39 - limit=100, 40 - ) 41 - ) 42 - 43 - repo_at_uri = None 44 - for record in records.records: 45 - if (name := getattr(record.value, "name", None)) is not None and name == repo_name: 46 - repo_at_uri = record.uri 47 - break 48 - 49 - if not repo_at_uri: 50 - raise ValueError(f"repo not found: {repo_id}") 51 - 52 - # list pull records from the authenticated user's collection 53 - response = client.com.atproto.repo.list_records( 54 - models.ComAtprotoRepoListRecords.Params( 55 - repo=client.me.did, 56 - collection="sh.tangled.repo.pull", 57 - limit=limit, 58 - ) 59 - ) 60 - 61 - # filter pulls by target repo and convert to dict format 62 - pulls = [] 63 - for record in response.records: 64 - value = record.value 65 - target = getattr(value, "target", None) 66 - if not target: 67 - continue 68 - 69 - target_repo = getattr(target, "repo", None) 70 - if target_repo != repo_at_uri: 71 - continue 72 - 73 - source = getattr(value, "source", {}) 74 - pulls.append( 75 - { 76 - "uri": record.uri, 77 - "cid": record.cid, 78 - "title": getattr(value, "title", ""), 79 - "source": { 80 - "sha": getattr(source, "sha", ""), 81 - "branch": getattr(source, "branch", ""), 82 - "repo": getattr(source, "repo", None), 83 - }, 84 - "target": { 85 - "repo": target_repo, 86 - "branch": getattr(target, "branch", ""), 87 - }, 88 - "createdAt": getattr(value, "createdAt", ""), 89 - } 90 - ) 91 - 92 - return {"pulls": pulls}
+11 -40
src/tangled_mcp/server.py
··· 11 11 DeleteIssueResult, 12 12 ListBranchesResult, 13 13 ListIssuesResult, 14 - ListPullsResult, 15 14 UpdateIssueResult, 16 15 ) 17 16 ··· 54 53 limit: Annotated[ 55 54 int, Field(ge=1, le=100, description="maximum number of branches to return") 56 55 ] = 50, 56 + cursor: Annotated[str | None, Field(description="pagination cursor")] = None, 57 57 ) -> ListBranchesResult: 58 58 """list branches for a repository 59 59 60 60 Args: 61 61 repo: repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp') 62 62 limit: maximum number of branches to return (1-100) 63 + cursor: optional pagination cursor 63 64 64 65 Returns: 65 - list of branches 66 + list of branches with optional cursor for pagination 66 67 """ 67 68 # resolve owner/repo to (knot, did/repo) 68 69 knot, repo_id = _tangled.resolve_repo_identifier(repo) 69 - response = _tangled.list_branches(knot, repo_id, limit, cursor=None) 70 + response = _tangled.list_branches(knot, repo_id, limit, cursor) 70 71 71 72 return ListBranchesResult.from_api_response(response) 72 73 ··· 105 106 # create_issue doesn't need knot (uses atproto putRecord, not XRPC) 106 107 response = _tangled.create_issue(repo_id, title, body, labels) 107 108 108 - return CreateIssueResult(repo=repo, id=response["issueId"]) 109 + return CreateIssueResult(repo=repo, issue_id=response["issueId"]) 109 110 110 111 111 112 @tangled_mcp.tool ··· 144 145 # update_issue doesn't need knot (uses atproto putRecord, not XRPC) 145 146 _tangled.update_issue(repo_id, issue_id, title, body, labels) 146 147 147 - return UpdateIssueResult(repo=repo, id=issue_id) 148 + return UpdateIssueResult(repo=repo, issue_id=issue_id) 148 149 149 150 150 151 @tangled_mcp.tool ··· 173 174 # delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC) 174 175 _tangled.delete_issue(repo_id, issue_id) 175 176 176 - return DeleteIssueResult(id=issue_id) 177 + return DeleteIssueResult(issue_id=issue_id) 177 178 178 179 179 180 @tangled_mcp.tool ··· 187 188 limit: Annotated[ 188 189 int, Field(ge=1, le=100, description="maximum number of issues to return") 189 190 ] = 20, 191 + cursor: Annotated[str | None, Field(description="pagination cursor")] = None, 190 192 ) -> ListIssuesResult: 191 193 """list issues for a repository 192 194 193 195 Args: 194 196 repo: repository identifier in 'owner/repo' format 195 197 limit: maximum number of issues to return (1-100) 198 + cursor: optional pagination cursor 196 199 197 200 Returns: 198 - ListIssuesResult with list of issues 201 + ListIssuesResult with list of issues and optional cursor 199 202 """ 200 203 # resolve owner/repo to (knot, did/repo) 201 204 _, repo_id = _tangled.resolve_repo_identifier(repo) 202 205 # list_repo_issues doesn't need knot (queries atproto records, not XRPC) 203 - response = _tangled.list_repo_issues(repo_id, limit, cursor=None) 206 + response = _tangled.list_repo_issues(repo_id, limit, cursor) 204 207 205 208 return ListIssuesResult.from_api_response(response) 206 209 ··· 226 229 _, repo_id = _tangled.resolve_repo_identifier(repo) 227 230 # list_repo_labels doesn't need knot (queries atproto records, not XRPC) 228 231 return _tangled.list_repo_labels(repo_id) 229 - 230 - 231 - @tangled_mcp.tool 232 - def 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"])
-5
src/tangled_mcp/types/__init__.py
··· 9 9 ListIssuesResult, 10 10 UpdateIssueResult, 11 11 ) 12 - from tangled_mcp.types._pulls import ListPullsResult, PullInfo, PullSource, PullTarget 13 12 14 13 __all__ = [ 15 14 "BranchInfo", ··· 18 17 "IssueInfo", 19 18 "ListBranchesResult", 20 19 "ListIssuesResult", 21 - "ListPullsResult", 22 - "PullInfo", 23 - "PullSource", 24 - "PullTarget", 25 20 "RepoIdentifier", 26 21 "UpdateIssueResult", 27 22 ]
+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"))
+13 -16
src/tangled_mcp/types/_issues.py
··· 18 18 19 19 uri: str 20 20 cid: str 21 - id: int = Field(alias="issueId") 21 + issue_id: int = Field(alias="issueId") 22 22 title: str 23 23 body: str | None = None 24 24 created_at: str = Field(alias="createdAt") ··· 28 28 class CreateIssueResult(BaseModel): 29 29 """result of creating an issue""" 30 30 31 - repo: RepoIdentifier = Field(exclude=True) 32 - id: int 31 + repo: RepoIdentifier 32 + issue_id: int 33 33 34 34 @computed_field 35 35 @property 36 36 def url(self) -> str: 37 37 """construct clickable tangled.org URL""" 38 - return _tangled_issue_url(self.repo, self.id) 38 + return _tangled_issue_url(self.repo, self.issue_id) 39 39 40 40 41 41 class UpdateIssueResult(BaseModel): 42 42 """result of updating an issue""" 43 43 44 - repo: RepoIdentifier = Field(exclude=True) 45 - id: int 44 + repo: RepoIdentifier 45 + issue_id: int 46 46 47 47 @computed_field 48 48 @property 49 49 def url(self) -> str: 50 50 """construct clickable tangled.org URL""" 51 - return _tangled_issue_url(self.repo, self.id) 51 + return _tangled_issue_url(self.repo, self.issue_id) 52 52 53 53 54 54 class DeleteIssueResult(BaseModel): 55 55 """result of deleting an issue""" 56 56 57 - id: int 57 + issue_id: int 58 58 59 59 60 60 class ListIssuesResult(BaseModel): 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 - issues = [] 89 - for issue_data in response.get("issues", []): 90 - # skip malformed issues (e.g., missing issueId) 91 - if issue_data.get("issueId") is None: 92 - continue 93 - issues.append(IssueInfo(**issue_data)) 94 - return cls(issues=issues) 90 + issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])] 91 + return cls(issues=issues, cursor=response.get("cursor"))
-70
src/tangled_mcp/types/_pulls.py
··· 1 - """pull request types""" 2 - 3 - from typing import Any 4 - 5 - from pydantic import BaseModel, Field 6 - 7 - 8 - class PullSource(BaseModel): 9 - """source branch info for a pull request""" 10 - 11 - sha: str 12 - branch: str 13 - repo: str | None = None # AT-URI of source repo (for cross-repo PRs) 14 - 15 - 16 - class PullTarget(BaseModel): 17 - """target branch info for a pull request""" 18 - 19 - repo: str # AT-URI of target repo 20 - branch: str 21 - 22 - 23 - class PullInfo(BaseModel): 24 - """pull request information""" 25 - 26 - uri: str 27 - cid: str 28 - title: str 29 - source: PullSource 30 - target: PullTarget 31 - created_at: str = Field(alias="createdAt") 32 - 33 - 34 - class ListPullsResult(BaseModel): 35 - """result of listing pull requests""" 36 - 37 - pulls: list[PullInfo] 38 - 39 - @classmethod 40 - def from_api_response(cls, pulls_data: list[dict[str, Any]]) -> "ListPullsResult": 41 - """construct from pre-filtered pull data 42 - 43 - Args: 44 - pulls_data: list of pull dicts already filtered by target repo 45 - 46 - Returns: 47 - ListPullsResult with parsed pulls 48 - """ 49 - pulls = [] 50 - for pull in pulls_data: 51 - source = pull.get("source", {}) 52 - target = pull.get("target", {}) 53 - pulls.append( 54 - PullInfo( 55 - uri=pull["uri"], 56 - cid=pull["cid"], 57 - title=pull.get("title", ""), 58 - source=PullSource( 59 - sha=source.get("sha", ""), 60 - branch=source.get("branch", ""), 61 - repo=source.get("repo"), 62 - ), 63 - target=PullTarget( 64 - repo=target.get("repo", ""), 65 - branch=target.get("branch", ""), 66 - ), 67 - createdAt=pull.get("createdAt", ""), 68 - ) 69 - ) 70 - return cls(pulls=pulls)
-43
tests/test_resolver.py
··· 2 2 3 3 import pytest 4 4 5 - from tangled_mcp._tangled._client import _extract_error_message 6 - 7 - 8 - class TestExtractErrorMessage: 9 - """test error message extraction from various exception types""" 10 - 11 - def test_extracts_from_atproto_response(self): 12 - """extracts message from atproto RequestErrorBase structure""" 13 - 14 - class MockContent: 15 - message = "handle must be a valid handle" 16 - 17 - class MockResponse: 18 - content = MockContent() 19 - 20 - class MockException(Exception): 21 - response = MockResponse() 22 - 23 - msg = _extract_error_message(MockException()) 24 - assert msg == "handle must be a valid handle" 25 - 26 - def test_truncates_long_messages(self): 27 - """truncates messages longer than 200 chars""" 28 - long_msg = "x" * 300 29 - 30 - class MockException(Exception): 31 - pass 32 - 33 - e = MockException(long_msg) 34 - msg = _extract_error_message(e) 35 - assert len(msg) == 203 # 200 + "..." 36 - assert msg.endswith("...") 37 - 38 - def test_handles_none_response(self): 39 - """handles exception with None response""" 40 - 41 - class MockException(Exception): 42 - response = None 43 - 44 - e = MockException("fallback message") 45 - msg = _extract_error_message(e) 46 - assert msg == "fallback message" 47 - 48 5 49 6 class TestRepoIdentifierParsing: 50 7 """test repository identifier format validation"""
+3 -2
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) == 7 25 + assert len(tools) == 6 26 26 27 27 tool_names = {tool.name for tool in tools} 28 28 assert "list_repo_branches" in tool_names ··· 31 31 assert "delete_repo_issue" in tool_names 32 32 assert "list_repo_issues" in tool_names 33 33 assert "list_repo_labels" in tool_names 34 - assert "list_repo_pulls" in tool_names 35 34 36 35 async def test_list_repo_branches_tool_schema(self): 37 36 """test list_repo_branches tool has correct schema""" ··· 56 55 assert properties["limit"]["minimum"] == 1 57 56 assert properties["limit"]["maximum"] == 100 58 57 assert properties["limit"]["default"] == 50 58 + 59 + assert "cursor" in properties 59 60 60 61 # required parameters 61 62 assert tool.inputSchema["required"] == ["repo"]
+18 -14
tests/test_types.py
··· 15 15 16 16 def test_strips_at_prefix(self): 17 17 """@ prefix is stripped during validation""" 18 - result = CreateIssueResult(repo="@owner/repo", id=1) 18 + result = CreateIssueResult(repo="@owner/repo", issue_id=1) 19 19 assert result.repo == "owner/repo" 20 20 21 21 def test_accepts_without_at_prefix(self): 22 22 """repo identifier without @ works""" 23 - result = CreateIssueResult(repo="owner/repo", id=1) 23 + result = CreateIssueResult(repo="owner/repo", issue_id=1) 24 24 assert result.repo == "owner/repo" 25 25 26 26 def test_rejects_invalid_format(self): 27 27 """repo identifier without slash is rejected""" 28 28 with pytest.raises(ValidationError, match="invalid repo format"): 29 - CreateIssueResult(repo="invalid", id=1) 29 + CreateIssueResult(repo="invalid", issue_id=1) 30 30 31 31 32 32 class TestIssueResultURLs: ··· 34 34 35 35 def test_create_issue_url(self): 36 36 """create result generates correct tangled.org URL""" 37 - result = CreateIssueResult(repo="owner/repo", id=42) 37 + result = CreateIssueResult(repo="owner/repo", issue_id=42) 38 38 assert result.url == "https://tangled.org/@owner/repo/issues/42" 39 39 40 40 def test_update_issue_url(self): 41 41 """update result generates correct tangled.org URL""" 42 - result = UpdateIssueResult(repo="owner/repo", id=42) 42 + result = UpdateIssueResult(repo="owner/repo", issue_id=42) 43 43 assert result.url == "https://tangled.org/@owner/repo/issues/42" 44 44 45 45 def test_url_handles_at_prefix_input(self): 46 46 """URL is correct even when input has @ prefix""" 47 - result = CreateIssueResult(repo="@owner/repo", id=42) 47 + result = CreateIssueResult(repo="@owner/repo", issue_id=42) 48 48 assert result.url == "https://tangled.org/@owner/repo/issues/42" 49 49 50 - def test_repo_excluded_from_serialization(self): 51 - """repo field is excluded from JSON output""" 52 - result = CreateIssueResult(repo="owner/repo", id=42) 53 - data = result.model_dump() 54 - assert "repo" not in data 55 - assert data["id"] == 42 56 - assert "url" in data 57 - 58 50 59 51 class TestListBranchesFromAPIResponse: 60 52 """test ListBranchesResult.from_api_response constructor""" ··· 66 58 {"reference": {"name": "main", "hash": "abc123"}}, 67 59 {"reference": {"name": "dev", "hash": "def456"}}, 68 60 ], 61 + "cursor": "next_page", 69 62 } 70 63 71 64 result = ListBranchesResult.from_api_response(response) ··· 75 68 assert result.branches[0].sha == "abc123" 76 69 assert result.branches[1].name == "dev" 77 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 78 81 79 82 def test_handles_empty_branches(self): 80 83 """handles empty branches list""" ··· 83 86 result = ListBranchesResult.from_api_response(response) 84 87 85 88 assert result.branches == [] 89 + assert result.cursor is None