@testplanit/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1346 @@
1
+ # @testplanit/mcp-server
2
+
3
+ Model Context Protocol server for [TestPlanIt](https://github.com/testplanit/testplanit) — exposes test-management data to AI agents (Claude Desktop, Cursor, etc.) over stdio JSON-RPC.
4
+
5
+ ## Quick install
6
+
7
+ ```sh
8
+ npx @testplanit/mcp-server
9
+ ```
10
+
11
+ The server runs as a stdio MCP transport — your MCP-aware client (Claude Desktop, Cursor, etc.) starts it on demand. There is no daemon to manage and no port to forward.
12
+
13
+ ## Environment variables
14
+
15
+ | Variable | Required | Description |
16
+ | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
17
+ | `TESTPLANIT_API_TOKEN` | yes | API token from your TestPlanIt profile. Must start with `tpi_`. Mint one under **Profile → API Tokens**. |
18
+ | `TESTPLANIT_API_URL` | yes | Base URL of your TestPlanIt instance (e.g. `https://testplanit.yourcompany.com`). |
19
+
20
+ The server validates `TESTPLANIT_API_TOKEN` against the TestPlanIt API on startup. Invalid, expired, or revoked tokens cause the server to exit with code 1 before the MCP handshake completes — the agent will report a clean failure rather than hang.
21
+
22
+ ## Token scopes
23
+
24
+ API tokens have two optional scope tags that change the server's behavior:
25
+
26
+ - **`mode:read`** — narrows the token to read-only operations across REST and MCP. The host enforces a single chokepoint that returns HTTP 403 with `code: "READ_ONLY_TOKEN"` on any write attempt; the MCP server translates that into a friendly agent-visible error. Recommended for AI agents that should be able to query data but never modify it.
27
+ - **`client:mcp`** — attributes audit-log entries from this token to the MCP source (`metadata.source: "mcp"`). The attribution is derived from the token scope itself — it cannot be forged by request-time headers. Recommended for any token used by an MCP-aware agent so administrators can correctly attribute agent-driven changes.
28
+
29
+ Set scopes when creating the token in **Profile → API Tokens** (checkboxes: "Read-only" and "Mark as agent token"). A token with no scopes behaves as a full-access traditional API token (backwards compatible).
30
+
31
+ ## Tool Catalog
32
+
33
+ Phase 6 + Phase 7 + Phase 8 ship 27 production tools across cases / folders / tags / projects / runs / sessions / findings / code-repositories / issues / repository-case-links, plus three milestones-domain read tools (`testplanit_milestones_list`, `testplanit_milestones_get`, `testplanit_milestone_types_list`), two issue-link write tools (`testplanit_issues_link`, `testplanit_issues_unlink`), and the `testplanit_whoami` debug helper. Phase 9 adds 8 write tools: four for the runs domain (`testplanit_runs_create`, `testplanit_runs_update`, `testplanit_runs_cases_add`, `testplanit_test_run_results_create`), two for sessions (`testplanit_sessions_create`, `testplanit_sessions_update`), and two for milestones (`testplanit_milestones_create`, `testplanit_milestones_update`) — **42 total**. All tools authenticate via the bearer token in `TESTPLANIT_API_TOKEN`. Read tools return JSON; write tools return the same shape as their corresponding `_get` tool.
34
+
35
+ ### Killer-app chain: "Who tested issue X?"
36
+
37
+ Two MCP calls give an agent the full executor lineage for an issue:
38
+
39
+ 1. `testplanit_cases_list({ projectId: P, issueId: I })` → returns RepositoryCases linked to issue I (Phase 7 / D7-03 — additive filter).
40
+ 2. `testplanit_test_run_results_list({ caseIds: [<from step 1>] })` → returns the most recent results (default `orderBy executedAt DESC` per D7-02) with `executedBy: { id, name, email }` inline.
41
+
42
+ No aggregate helper tool needed — the two-call composition is reusable for PR-diff impact (Phase 8) and any future "what tested this?" prompts.
43
+
44
+ ### Context
45
+
46
+ #### `testplanit_whoami`
47
+
48
+ Debug helper. Returns the authenticated user (token owner), email, and scopes.
49
+
50
+ **Input:** None
51
+
52
+ **Output:**
53
+ ```json
54
+ { "id": "user-1", "name": "Alice", "email": "alice@example.com", "scopes": ["client:mcp"] }
55
+ ```
56
+
57
+ #### `testplanit_projects_list`
58
+
59
+ List all projects the token has access to. Use this to discover `projectId` for downstream tool calls.
60
+
61
+ **Input:** None
62
+
63
+ **Output:**
64
+ ```json
65
+ { "projects": [{ "id": 1, "name": "TestProject" }] }
66
+ ```
67
+
68
+ ### Cases
69
+
70
+ #### `testplanit_cases_list`
71
+
72
+ List test cases scoped to a project. Supports filters and cursor-based pagination.
73
+
74
+ **Input:**
75
+ ```json
76
+ {
77
+ "projectId": 1,
78
+ "folderId": 2,
79
+ "tagIds": [3, 4],
80
+ "name": "login",
81
+ "stateId": 5,
82
+ "customField": { "name": "Priority" },
83
+ "issueId": 55,
84
+ "cursor": 100,
85
+ "limit": 25
86
+ }
87
+ ```
88
+
89
+ The `issueId` filter (Phase 7 / D7-03 — additive) returns RepositoryCases linked to the named Issue. Used as the front-half of the killer-app chain `cases_list({issueId}) → test_run_results_list({caseIds})`.
90
+
91
+ **Phase 8 maintenance filters (D8-01 / D8-02 — additive):** seven new filters narrow the list to test-maintenance questions:
92
+
93
+ - `automated: boolean` — narrows to user-flagged automated tests (`RepositoryCases.automated`); independent of `source`.
94
+ - `source: 'MANUAL'|'JUNIT'|'TESTNG'|'XUNIT'|'NUNIT'|'MSTEST'|'MOCHA'|'CUCUMBER'|'API'` — single value or array; the import-format that originally created the row.
95
+ - `repositoryId: number` — scopes to a specific per-project case container (useful for multi-repository projects).
96
+ - `hasNeverExecuted: boolean` — narrows to cases with **zero** JUnit results AND zero TestRunResults (via TestRunCases).
97
+ - `staleSinceUpdate: boolean` — narrows to cases whose latest execution timestamp is earlier than the latest update timestamp (or never executed). Implemented as a handler-side post-filter with a bounded scan cap of 400 rows; the response stamps `truncated: true` when the cap is hit.
98
+ - `updatedAfter: ISODate`, `updatedBefore: ISODate` — calendar filters that route through the `repositoryCaseVersions` relation (`RepositoryCases` has no `updatedAt` column).
99
+
100
+ **Creator + creation-date filters (additive):** three more optional inputs narrow the list to authorship questions like *"How many test cases did I write last month?"*:
101
+
102
+ - `creatorIds: string[]` — array of user IDs; matches any. Deliberately wider than `runs_list` / `sessions_list` `createdById` (single string) — a frequent question is "what did **anyone on my team** write" and the array shape avoids round-tripping through union of single-creator calls.
103
+ - `from: ISODate`, `to: ISODate` — `createdAt` range filter. Naming mirrors `runs_list` / `sessions_list` for consistency.
104
+
105
+ **Phase 8 row fields (additive):** every row carries `lastUpdatedAt` (from `repositoryCaseVersions[currentVersion].createdAt`) and `latestResult: { id, status, executedAt, source: 'TestRun' | 'JUnit' } | null` (the most recent execution across both pipelines, with a `source` discriminator). Returns `null` when the case has never been executed.
106
+
107
+ **Output:**
108
+ ```json
109
+ {
110
+ "items": [
111
+ {
112
+ "id": 99,
113
+ "name": "Login flow",
114
+ "source": "MANUAL",
115
+ "automated": false,
116
+ "createdAt": "2026-01-01T00:00:00.000Z",
117
+ "project": { "id": 1, "name": "TestProject" },
118
+ "folder": { "id": 2, "name": "Auth" },
119
+ "state": { "id": 5, "name": "Active" },
120
+ "creator": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
121
+ "tags": [{ "id": 3, "name": "regression" }]
122
+ }
123
+ ],
124
+ "hasNextPage": false,
125
+ "nextCursor": null
126
+ }
127
+ ```
128
+
129
+ When `hasNextPage` is `true`, pass `nextCursor` as `cursor` to fetch the next page.
130
+
131
+ #### `testplanit_cases_get`
132
+
133
+ Fetch full details for a single test case, including steps (plain text), custom fields (flat dict keyed by display name), folder breadcrumb, linked issues, and linked automated tests. **Phase 8 (additive):** the response now includes inline `codeRepository: { id, name, type, url? } | null` derived from the project's configured `ProjectCodeRepositoryConfig.repository` — surfaces the test-framework source location alongside the existing case detail. The repository's `credentials` column is never returned and `settings` is stripped to a per-provider public-key allow-list.
134
+
135
+ **Input:**
136
+ ```json
137
+ { "caseId": 99 }
138
+ ```
139
+
140
+ **Output:**
141
+ ```json
142
+ {
143
+ "id": 99,
144
+ "name": "Login flow",
145
+ "source": "MANUAL",
146
+ "automated": false,
147
+ "createdAt": "2026-01-01T00:00:00.000Z",
148
+ "project": { "id": 1, "name": "TestProject" },
149
+ "folder": { "id": 2, "name": "Auth" },
150
+ "folderBreadcrumb": [{ "id": 10, "name": "Regression" }, { "id": 2, "name": "Auth" }],
151
+ "folderFullPath": "Regression / Auth",
152
+ "state": { "id": 5, "name": "Active" },
153
+ "creator": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
154
+ "tags": [{ "id": 3, "name": "regression" }],
155
+ "steps": [
156
+ { "id": 1, "order": 0, "step": "Open the login page", "expectedResult": "Login form is visible" }
157
+ ],
158
+ "customFields": { "Priority": "High", "Severity": 2 },
159
+ "issues": [{ "id": 55, "externalKey": "JIRA-99", "title": "Login bug", "externalStatus": "Open" }],
160
+ "linkedAutomatedTests": [{ "id": 9, "name": "automated_test_a", "source": "JUNIT" }]
161
+ }
162
+ ```
163
+
164
+ #### `testplanit_cases_create`
165
+
166
+ Create a new test case. Returns the full CASE-02 shape (same as `testplanit_cases_get`).
167
+
168
+ **Input:**
169
+ ```json
170
+ {
171
+ "projectId": 1,
172
+ "folderId": 2,
173
+ "name": "New login test",
174
+ "stateName": "Active",
175
+ "steps": [
176
+ { "text": "Open the login page", "expectedResult": "Login form is visible", "order": 0 }
177
+ ],
178
+ "tags": [3, "regression"],
179
+ "customFields": { "Priority": "High" }
180
+ }
181
+ ```
182
+
183
+ - `stateName` — defaults to the first CASES-scope workflow state for the project.
184
+ - `tags` — accepts tag IDs (numbers) or tag names (strings, created if missing).
185
+ - `customFields` — flat dict keyed by display name; unknown names return a structured error.
186
+
187
+ **Output:** Same shape as `testplanit_cases_get`.
188
+
189
+ #### `testplanit_cases_update`
190
+
191
+ Partially update a test case (name, stateName, folderId, steps, tags, customFields). Providing `steps` replaces the entire step set (old steps are soft-deleted). Returns the full CASE-02 shape.
192
+
193
+ **Input:**
194
+ ```json
195
+ {
196
+ "caseId": 99,
197
+ "name": "Updated name",
198
+ "stateName": "In Progress",
199
+ "folderId": 3,
200
+ "steps": [{ "text": "Step 1", "expectedResult": "Expected 1" }],
201
+ "tags": ["smoke"],
202
+ "customFields": { "Priority": "Low" }
203
+ }
204
+ ```
205
+
206
+ **Output:** Same shape as `testplanit_cases_get`.
207
+
208
+ #### `testplanit_cases_delete`
209
+
210
+ Soft-delete a test case (sets `isDeleted: true`). The case is hidden from subsequent list/get calls but retained in the database for audit purposes.
211
+
212
+ **Input:**
213
+ ```json
214
+ { "caseId": 99 }
215
+ ```
216
+
217
+ **Output:**
218
+ ```json
219
+ { "id": 99, "isDeleted": true }
220
+ ```
221
+
222
+ ### Folders
223
+
224
+ #### `testplanit_folders_list`
225
+
226
+ List all folders for a project as a tree. Returns root folders with up to 2 levels of children inline. Each node includes a case count (non-deleted cases only). For deeper subtrees, use `testplanit_folders_get`.
227
+
228
+ **Input:**
229
+ ```json
230
+ { "projectId": 1 }
231
+ ```
232
+
233
+ **Output:**
234
+ ```json
235
+ {
236
+ "tree": [
237
+ {
238
+ "id": 10,
239
+ "name": "Regression",
240
+ "parentId": null,
241
+ "caseCount": 3,
242
+ "children": [
243
+ { "id": 2, "name": "Auth", "parentId": 10, "caseCount": 1, "children": [] }
244
+ ]
245
+ }
246
+ ]
247
+ }
248
+ ```
249
+
250
+ #### `testplanit_folders_get`
251
+
252
+ Fetch full details for a single folder, including parent breadcrumb, direct children, and a summary of cases (capped at 100 rows).
253
+
254
+ **Input:**
255
+ ```json
256
+ { "folderId": 2 }
257
+ ```
258
+
259
+ **Output:**
260
+ ```json
261
+ {
262
+ "id": 2,
263
+ "name": "Auth",
264
+ "parentId": 10,
265
+ "breadcrumb": [{ "id": 10, "name": "Regression" }, { "id": 2, "name": "Auth" }],
266
+ "fullPath": "Regression / Auth",
267
+ "children": [{ "id": 20, "name": "OAuth", "parentId": 2, "caseCount": 0, "children": [] }],
268
+ "cases": [{ "id": 99, "name": "Login flow" }],
269
+ "caseCount": 1
270
+ }
271
+ ```
272
+
273
+ #### `testplanit_folders_create`
274
+
275
+ Create a folder. Omit `parentId` for a root folder. Returns the full `testplanit_folders_get` shape.
276
+
277
+ **Input:**
278
+ ```json
279
+ {
280
+ "projectId": 1,
281
+ "name": "New Folder",
282
+ "parentId": 10
283
+ }
284
+ ```
285
+
286
+ **Output:** Same shape as `testplanit_folders_get`.
287
+
288
+ #### `testplanit_folders_update`
289
+
290
+ Rename a folder, reparent it, or both. Pass `parentId: null` to move the folder to root (disconnect from parent). Returns the full `testplanit_folders_get` shape.
291
+
292
+ **Input:**
293
+ ```json
294
+ {
295
+ "folderId": 2,
296
+ "name": "Auth Tests",
297
+ "parentId": null
298
+ }
299
+ ```
300
+
301
+ **Output:** Same shape as `testplanit_folders_get`.
302
+
303
+ #### `testplanit_folders_delete`
304
+
305
+ Soft-delete a folder. The tool checks that the folder has no active cases and no active sub-folders before issuing the delete — non-empty folders surface a structured CASE-12 error naming the violation. Returns `{ id, isDeleted: true }` on success.
306
+
307
+ **Input:**
308
+ ```json
309
+ { "folderId": 2 }
310
+ ```
311
+
312
+ **Output:**
313
+ ```json
314
+ { "id": 2, "isDeleted": true }
315
+ ```
316
+
317
+ ### Tags
318
+
319
+ #### `testplanit_tags_list`
320
+
321
+ List all tags (global). When `projectId` is provided, usage counts are scoped to that project's cases, test runs, and sessions.
322
+
323
+ **Input:**
324
+ ```json
325
+ { "projectId": 1 }
326
+ ```
327
+
328
+ **Output:**
329
+ ```json
330
+ {
331
+ "tags": [
332
+ {
333
+ "id": 3,
334
+ "name": "regression",
335
+ "usageCounts": { "repositoryCases": 12, "testRuns": 5, "sessions": 0 }
336
+ }
337
+ ]
338
+ }
339
+ ```
340
+
341
+ ### Execution + Session Read (Phase 7)
342
+
343
+ Phase 7 ships 10 read-only tools across the test-execution domain (5 run-side tools + 5 session-side tools). All carry `isDeleted: false` filters, deterministic `[{<order>:'desc'},{id:'desc'}]` orderBy, and cursor pagination capped at `limit: 100` (T-07-06 DoS guard).
344
+
345
+ #### `testplanit_test_runs_list` (EXEC-01)
346
+
347
+ List test runs scoped to a project. Each row carries `statusCounts: [{id,name,count}]` + `untested` + `total` inline (per D7-06 — counts SUM to `total` per R3 invariant). The rollup is fetched via a SINGLE batched groupBy per page (no N+1) so an agent can list 100 runs in one tool call.
348
+
349
+ **Input:**
350
+ ```json
351
+ {
352
+ "projectId": 1,
353
+ "stateId": 3,
354
+ "isCompleted": false,
355
+ "createdById": "user-1",
356
+ "from": "2026-01-01T00:00:00Z",
357
+ "to": "2026-02-01T00:00:00Z",
358
+ "cursor": 100,
359
+ "limit": 25
360
+ }
361
+ ```
362
+
363
+ **Output:**
364
+ ```json
365
+ {
366
+ "items": [
367
+ {
368
+ "id": 5,
369
+ "name": "Sprint 12 regression",
370
+ "isCompleted": false,
371
+ "completedAt": null,
372
+ "createdAt": "2026-01-05T00:00:00Z",
373
+ "testRunType": "REGULAR",
374
+ "project": { "id": 1, "name": "TestProject" },
375
+ "state": { "id": 3, "name": "In Progress" },
376
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
377
+ "configuration": null,
378
+ "milestone": null,
379
+ "tags": [],
380
+ "issues": [],
381
+ "statusCounts": [
382
+ { "id": 1, "name": "Passed", "count": 5 },
383
+ { "id": 2, "name": "Failed", "count": 2 }
384
+ ],
385
+ "untested": 1,
386
+ "total": 8
387
+ }
388
+ ],
389
+ "hasNextPage": false,
390
+ "nextCursor": null
391
+ }
392
+ ```
393
+
394
+ **Example:** "List the most recent 10 test runs in project 5."
395
+
396
+ #### `testplanit_test_runs_get` (EXEC-02)
397
+
398
+ Fetch a single test run with the first 50 testCases inline + status rollup. Each inline testCase carries `latestResult: { id, status, executedBy, executedAt }` so the agent sees the most-recent execution per case in one call.
399
+
400
+ **Input:**
401
+ ```json
402
+ { "runId": 5 }
403
+ ```
404
+
405
+ **Output:**
406
+ ```json
407
+ {
408
+ "id": 5,
409
+ "name": "Sprint 12 regression",
410
+ "project": { "id": 1, "name": "TestProject" },
411
+ "state": { "id": 3, "name": "In Progress" },
412
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
413
+ "statusCounts": [{ "id": 1, "name": "Passed", "count": 5 }],
414
+ "untested": 1,
415
+ "total": 8,
416
+ "testCases": [
417
+ {
418
+ "id": 100,
419
+ "order": 0,
420
+ "isCompleted": false,
421
+ "repositoryCase": { "id": 99, "name": "Login flow", "source": "MANUAL" },
422
+ "assignedTo": null,
423
+ "status": { "id": 1, "name": "Passed" },
424
+ "latestResult": {
425
+ "id": 555,
426
+ "status": { "id": 1, "name": "Passed" },
427
+ "executedBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
428
+ "executedAt": "2026-01-05T01:00:00Z"
429
+ }
430
+ }
431
+ ],
432
+ "testCasesNextCursor": null
433
+ }
434
+ ```
435
+
436
+ **Example:** "Show me the details of run 5 including the status counts."
437
+
438
+ #### `testplanit_test_runs_cases_list` (EXEC-03)
439
+
440
+ Paginate testCases for a run beyond the 50-cap returned by `testplanit_test_runs_get`. Same row shape as the inline `testCases[i]`.
441
+
442
+ **Input:**
443
+ ```json
444
+ {
445
+ "runId": 5,
446
+ "isCompleted": false,
447
+ "statusId": 2,
448
+ "assignedToId": "user-1",
449
+ "cursor": 100,
450
+ "limit": 25
451
+ }
452
+ ```
453
+
454
+ **Output:** `{ items: [...], hasNextPage, nextCursor }` — items match the `testCases[i]` shape from `testplanit_test_runs_get`.
455
+
456
+ **Example:** "List the failed cases in run 5."
457
+
458
+ #### `testplanit_test_run_results_list` (EXEC-04 + EXEC-06 back-half)
459
+
460
+ List test-run results with denormalized status / executedBy / testRunCase. The `caseIds` filter ships the **back-half** of the killer-app chain — pass the RepositoryCase ids returned by `testplanit_cases_list({issueId})` to get the latest results per case (`orderBy executedAt DESC` matches the schema index).
461
+
462
+ **Input:**
463
+ ```json
464
+ {
465
+ "runId": 5,
466
+ "caseIds": [99, 100],
467
+ "executedById": "user-1",
468
+ "statusId": 2,
469
+ "from": "2026-01-01T00:00:00Z",
470
+ "to": "2026-02-01T00:00:00Z",
471
+ "cursor": 200,
472
+ "limit": 25
473
+ }
474
+ ```
475
+
476
+ **Output:**
477
+ ```json
478
+ {
479
+ "items": [
480
+ {
481
+ "id": 555,
482
+ "attempt": 1,
483
+ "executedAt": "2026-01-05T01:00:00Z",
484
+ "status": { "id": 1, "name": "Passed" },
485
+ "executedBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
486
+ "testRunCase": {
487
+ "id": 100,
488
+ "repositoryCaseId": 99,
489
+ "repositoryCase": { "id": 99, "name": "Login flow", "source": "MANUAL" },
490
+ "testRun": { "id": 5, "name": "Sprint 12 regression" }
491
+ }
492
+ }
493
+ ],
494
+ "hasNextPage": false,
495
+ "nextCursor": null
496
+ }
497
+ ```
498
+
499
+ **Example:** "Who tested the cases linked to issue JIRA-42?" — chain `cases_list({issueId})` then this tool with `caseIds`.
500
+
501
+ #### `testplanit_test_run_results_get` (EXEC-05)
502
+
503
+ Drill-down — fetch a single test-run result with `stepResults: [...]` inlined. Each step result carries `stepText` / `expectedResultText` (ProseMirror-extracted) + `status` (from the `stepStatus` relation per R2) + `notes` + `evidence` (as-is per D7-08) + `attachments` + `issues`. Top-level result carries `customFields` denormalized and the parent `testRunCase` summary.
504
+
505
+ **Input:**
506
+ ```json
507
+ { "resultId": 555 }
508
+ ```
509
+
510
+ **Output:**
511
+ ```json
512
+ {
513
+ "id": 555,
514
+ "attempt": 1,
515
+ "executedAt": "2026-01-05T01:00:00Z",
516
+ "elapsed": 320,
517
+ "notes": "Step 2 had a 200ms hiccup",
518
+ "evidence": { "url": "https://traces.example.com/run-5/case-100" },
519
+ "status": { "id": 1, "name": "Passed" },
520
+ "executedBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
521
+ "testRunCase": {
522
+ "id": 100,
523
+ "repositoryCaseId": 99,
524
+ "repositoryCase": { "id": 99, "name": "Login flow", "source": "MANUAL" },
525
+ "testRun": { "id": 5, "name": "Sprint 12 regression" }
526
+ },
527
+ "customFields": { "Priority": "High" },
528
+ "stepResults": [
529
+ {
530
+ "id": 7000,
531
+ "status": { "id": 1, "name": "Passed" },
532
+ "stepId": 1,
533
+ "stepOrder": 0,
534
+ "stepText": "Open the login page",
535
+ "expectedResultText": "Login form is visible",
536
+ "notes": "",
537
+ "evidence": null,
538
+ "executedAt": "2026-01-05T01:00:00Z",
539
+ "elapsed": 80,
540
+ "attachments": [],
541
+ "issues": []
542
+ }
543
+ ],
544
+ "attachments": [],
545
+ "issues": []
546
+ }
547
+ ```
548
+
549
+ **Example:** "Show me the step-level breakdown for result 555."
550
+
551
+ #### `testplanit_runs_create`
552
+
553
+ Create a new test run. Optionally adds repository test cases in the same call (up to 250). Defaults to the first RUNS-scope workflow state if `stateName` is omitted.
554
+
555
+ **Input:**
556
+ ```json
557
+ {
558
+ "projectId": 1,
559
+ "name": "Sprint 13 regression",
560
+ "caseIds": [99, 100, 101],
561
+ "milestoneId": 7,
562
+ "configId": 3,
563
+ "stateName": "In Progress",
564
+ "tags": [3, "smoke"]
565
+ }
566
+ ```
567
+
568
+ - `caseIds` — optional, max 250; appended as TestRunCases in order.
569
+ - `tags` — accepts tag IDs (numbers) or tag names (strings, created if missing).
570
+ - `stateName` — defaults to the first RUNS-scope workflow state for the project.
571
+
572
+ **Output:** Same shape as `testplanit_test_runs_get`.
573
+
574
+ #### `testplanit_runs_update`
575
+
576
+ Update an existing test run. Pass `milestoneId: null` or `configId: null` to remove those associations. Providing `tags` replaces the full tag set.
577
+
578
+ **Input:**
579
+ ```json
580
+ {
581
+ "runId": 5,
582
+ "name": "Sprint 13 regression (updated)",
583
+ "stateName": "Completed",
584
+ "milestoneId": null,
585
+ "configId": null,
586
+ "tags": ["smoke"],
587
+ "isCompleted": true
588
+ }
589
+ ```
590
+
591
+ **Output:** Same shape as `testplanit_test_runs_get`.
592
+
593
+ #### `testplanit_runs_cases_add`
594
+
595
+ Add repository test cases to an existing run. Cases are appended in order after any existing cases; duplicates are skipped silently.
596
+
597
+ **Input:**
598
+ ```json
599
+ {
600
+ "runId": 5,
601
+ "caseIds": [102, 103, 104]
602
+ }
603
+ ```
604
+
605
+ - `caseIds` — required, 1–250.
606
+
607
+ **Output:**
608
+ ```json
609
+ { "runId": 5, "requested": 3, "total": 11 }
610
+ ```
611
+
612
+ - `requested` — number of caseIds submitted.
613
+ - `total` — total TestRunCases in the run after the add (including pre-existing cases).
614
+
615
+ #### `testplanit_test_run_results_create`
616
+
617
+ Submit a test result for a case in a run. Atomically creates the result and updates the run case's current status. The attempt number is auto-incremented — callers do not track it.
618
+
619
+ **Input:**
620
+ ```json
621
+ {
622
+ "testRunCaseId": 100,
623
+ "statusName": "Passed",
624
+ "notes": "All steps green.",
625
+ "elapsed": 320
626
+ }
627
+ ```
628
+
629
+ - `statusName` — matched by name within the project's configured statuses.
630
+ - `elapsed` — optional duration in seconds; pass `null` to omit.
631
+
632
+ **Output:** Same shape as `testplanit_test_run_results_get`.
633
+
634
+ #### `testplanit_sessions_list` (SESS-01)
635
+
636
+ List exploratory sessions scoped to a project, with denormalized state / createdBy / assignedTo / template / configuration / milestone / tags. Mission and note are extracted from ProseMirror to plain text.
637
+
638
+ **Input:**
639
+ ```json
640
+ {
641
+ "projectId": 1,
642
+ "stateId": 3,
643
+ "isCompleted": false,
644
+ "createdById": "user-1",
645
+ "from": "2026-01-01T00:00:00Z",
646
+ "to": "2026-02-01T00:00:00Z",
647
+ "cursor": 50,
648
+ "limit": 25
649
+ }
650
+ ```
651
+
652
+ **Output:**
653
+ ```json
654
+ {
655
+ "items": [
656
+ {
657
+ "id": 12,
658
+ "name": "Login flow exploration",
659
+ "isCompleted": false,
660
+ "completedAt": null,
661
+ "createdAt": "2026-01-10T00:00:00Z",
662
+ "mission": "Explore login edge cases",
663
+ "note": "",
664
+ "project": { "id": 1, "name": "TestProject" },
665
+ "state": { "id": 3, "name": "In Progress" },
666
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
667
+ "assignedTo": null,
668
+ "template": { "id": 4, "name": "ExploratoryTemplate" },
669
+ "configuration": null,
670
+ "milestone": null,
671
+ "tags": []
672
+ }
673
+ ],
674
+ "hasNextPage": false,
675
+ "nextCursor": null
676
+ }
677
+ ```
678
+
679
+ **Example:** "List my open sessions in project 1."
680
+
681
+ #### `testplanit_sessions_get` (SESS-02)
682
+
683
+ Fetch a single session with up to 100 sessionResults inlined and a `truncated: boolean` marker (D7-12). When `truncated: true`, paginate the rest via `testplanit_session_results_list({sessionId})`.
684
+
685
+ **Input:**
686
+ ```json
687
+ { "sessionId": 12 }
688
+ ```
689
+
690
+ **Output:**
691
+ ```json
692
+ {
693
+ "id": 12,
694
+ "name": "Login flow exploration",
695
+ "mission": "Explore login edge cases",
696
+ "note": "",
697
+ "project": { "id": 1, "name": "TestProject" },
698
+ "state": { "id": 3, "name": "In Progress" },
699
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
700
+ "issues": [],
701
+ "customFields": {},
702
+ "sessionResults": [
703
+ {
704
+ "id": 800,
705
+ "createdAt": "2026-01-10T00:30:00Z",
706
+ "elapsed": 600,
707
+ "resultDataText": "Tried 5 invalid passwords; UI showed correct error.",
708
+ "status": { "id": 1, "name": "Passed" },
709
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
710
+ "session": { "id": 12, "name": "Login flow exploration", "projectId": 1 }
711
+ }
712
+ ],
713
+ "truncated": false
714
+ }
715
+ ```
716
+
717
+ **Example:** "Show me session 12 with its results."
718
+
719
+ #### `testplanit_sessions_create`
720
+
721
+ Create a new exploratory test session. Auto-resolves the default template and the first SESSIONS-scope workflow state if not specified.
722
+
723
+ **Input:**
724
+ ```json
725
+ {
726
+ "projectId": 1,
727
+ "name": "Login edge case exploration",
728
+ "mission": "Explore login edge cases under slow network conditions",
729
+ "milestoneId": 7,
730
+ "configId": 3,
731
+ "stateName": "In Progress",
732
+ "tags": [3, "exploratory"]
733
+ }
734
+ ```
735
+
736
+ - `mission` — optional plain-text mission statement.
737
+ - `stateName` — defaults to the first SESSIONS-scope workflow state for the project.
738
+ - `tags` — accepts tag IDs (numbers) or tag names (strings, created if missing).
739
+
740
+ **Output:** Same shape as `testplanit_sessions_get`.
741
+
742
+ #### `testplanit_sessions_update`
743
+
744
+ Update an existing session. Pass `mission: null`, `milestoneId: null`, or `configId: null` to remove those fields. Providing `tags` replaces the full tag set.
745
+
746
+ **Input:**
747
+ ```json
748
+ {
749
+ "sessionId": 12,
750
+ "name": "Login edge case exploration (updated)",
751
+ "mission": null,
752
+ "stateName": "Completed",
753
+ "milestoneId": null,
754
+ "configId": null,
755
+ "tags": ["smoke"],
756
+ "isCompleted": true
757
+ }
758
+ ```
759
+
760
+ **Output:** Same shape as `testplanit_sessions_get`.
761
+
762
+ #### `testplanit_session_results_list` (SESS-03)
763
+
764
+ List session results with filters `sessionId` / `createdById` / `statusId`. **NO** `testCaseId` filter — Sessions are exploratory and SessionResults has no `testCaseId` FK (R4 invariant; enforced at the input schema and at the where-clause Prisma type).
765
+
766
+ **Input:**
767
+ ```json
768
+ {
769
+ "sessionId": 12,
770
+ "createdById": "user-1",
771
+ "statusId": 2,
772
+ "cursor": 100,
773
+ "limit": 25
774
+ }
775
+ ```
776
+
777
+ **Output:** `{ items, hasNextPage, nextCursor }` — items match the `sessionResults[i]` shape from `testplanit_sessions_get`.
778
+
779
+ **Example:** "List failed results in session 12."
780
+
781
+ #### `testplanit_session_results_get` (SESS-04)
782
+
783
+ Fetch a single session result with denormalized status / `createdBy` (the executor — D7-13: NO separate `executedBy` field) / session / customFields / attachments / issues. **NO** step-level results — sessions are exploratory and don't have ordered steps.
784
+
785
+ **Input:**
786
+ ```json
787
+ { "resultId": 800 }
788
+ ```
789
+
790
+ **Output:**
791
+ ```json
792
+ {
793
+ "id": 800,
794
+ "createdAt": "2026-01-10T00:30:00Z",
795
+ "elapsed": 600,
796
+ "resultDataText": "Tried 5 invalid passwords; UI showed correct error.",
797
+ "status": { "id": 1, "name": "Passed" },
798
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
799
+ "session": { "id": 12, "name": "Login flow exploration", "projectId": 1 },
800
+ "customFields": { "Severity": "Low" },
801
+ "attachments": [],
802
+ "issues": []
803
+ }
804
+ ```
805
+
806
+ **Example:** "Show me session result 800."
807
+
808
+ #### `testplanit_sessions_findings_list` (SESS-05)
809
+
810
+ Dual-mode (XOR — provide exactly one of `sessionId` or `issueId`):
811
+
812
+ - **sessionId mode** — returns Issue rows linked to the session OR to any of its sessionResults. Mirrors the agent-facing prompt "what issues surfaced in session X?"
813
+ - **issueId mode** — returns the issue plus its `linkedSessions` and `linkedSessionResults`. Mirrors "where did issue Y appear?"
814
+
815
+ **Input (sessionId mode):**
816
+ ```json
817
+ { "sessionId": 12 }
818
+ ```
819
+
820
+ **Output (sessionId mode):**
821
+ ```json
822
+ {
823
+ "session": { "id": 12, "name": "Login flow exploration" },
824
+ "findings": [
825
+ {
826
+ "id": 55,
827
+ "externalKey": "JIRA-99",
828
+ "title": "Login fails on Safari",
829
+ "status": "Open",
830
+ "externalStatus": "Open",
831
+ "priority": "High",
832
+ "externalSystem": "JIRA"
833
+ }
834
+ ],
835
+ "truncated": false
836
+ }
837
+ ```
838
+
839
+ **Input (issueId mode):**
840
+ ```json
841
+ { "issueId": 55 }
842
+ ```
843
+
844
+ **Output (issueId mode):**
845
+ ```json
846
+ {
847
+ "issue": { "id": 55, "externalKey": "JIRA-99", "title": "Login fails on Safari", "externalStatus": "Open", "externalSystem": "JIRA" },
848
+ "linkedSessions": [
849
+ {
850
+ "id": 12,
851
+ "name": "Login flow exploration",
852
+ "createdAt": "2026-01-10T00:00:00Z",
853
+ "isCompleted": false,
854
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" }
855
+ }
856
+ ],
857
+ "linkedSessionResults": [
858
+ {
859
+ "id": 800,
860
+ "sessionId": 12,
861
+ "createdAt": "2026-01-10T00:30:00Z",
862
+ "status": { "id": 1, "name": "Passed" },
863
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" }
864
+ }
865
+ ]
866
+ }
867
+ ```
868
+
869
+ **Example:** "What sessions surfaced JIRA-99?"
870
+
871
+ > Note: For resolving an external key like `JIRA-99` to an Issue, see `testplanit_issues_find_by_key` (Phase 8 — ISSUE-01). The lookup tuple is `(externalKey, externalSystem, projectId)` to disambiguate when multiple integrations of the same provider exist (`Issue.externalKey` is not globally unique — schema enforces only `@@unique([externalId, integrationId])`).
872
+
873
+ ### Code Repositories
874
+
875
+ #### `testplanit_code_repositories_list` (Phase 8 — REPO-01)
876
+
877
+ List the project's code-repository configuration. Returns `ProjectCodeRepositoryConfig` rows with the underlying `CodeRepository` denormalized inline. The schema enforces `@@unique([projectId])` so the response carries 0 or 1 row today; cursor pagination is shape-compatible with future multi-config relaxation.
878
+
879
+ **Input:**
880
+ ```json
881
+ { "projectId": 1, "cursor": 100, "limit": 25 }
882
+ ```
883
+
884
+ **Output:**
885
+ ```json
886
+ {
887
+ "items": [
888
+ {
889
+ "id": 1,
890
+ "projectId": 42,
891
+ "branch": "main",
892
+ "pathPatterns": [{ "path": "src/**", "pattern": "*.test.ts" }],
893
+ "cacheEnabled": true,
894
+ "cacheTtlDays": 7,
895
+ "cacheStatus": "READY",
896
+ "cacheLastFetchedAt": "2026-05-01T12:00:00Z",
897
+ "cacheFileCount": 1234,
898
+ "cacheTotalSize": 5678901,
899
+ "cacheError": null,
900
+ "repository": {
901
+ "id": 5,
902
+ "name": "acme/tools",
903
+ "provider": "GITHUB",
904
+ "status": "ACTIVE",
905
+ "lastTestedAt": "2026-04-30T08:00:00Z",
906
+ "settings": { "owner": "acme", "repo": "tools" },
907
+ "url": "https://github.com/acme/tools"
908
+ }
909
+ }
910
+ ],
911
+ "hasNextPage": false,
912
+ "nextCursor": null
913
+ }
914
+ ```
915
+
916
+ > **Security (T-08-CRED-LEAK):** the underlying `credentials` column is never selected (defense-in-depth #1 — TS2353 at compile time if reintroduced). The wholesale `settings` JSON is stripped to a per-provider public-key allow-list at the mapper boundary (defense-in-depth #2): `GITHUB ["owner","repo"]`, `GITLAB / BITBUCKET / GITEA ["baseUrl","owner","repo"]`, `AZURE_DEVOPS ["organizationUrl","project","repositoryId"]`. The derived web `url` follows each provider's canonical pattern; trailing slashes sanitized.
917
+
918
+ ### Issues
919
+
920
+ #### `testplanit_issues_find_by_key` (Phase 8 — ISSUE-01)
921
+
922
+ Resolve `(externalKey, externalSystem, projectId)` to an Issue. The schema enforces only `@@unique([externalId, integrationId])` — `externalKey` is NOT globally unique when multiple integrations of the same provider exist in the same project. The tool's input tuple matches how integrations are configured in practice and how an agent already disambiguates project context.
923
+
924
+ **Input:**
925
+ ```json
926
+ {
927
+ "externalKey": "JIRA-123",
928
+ "externalSystem": "JIRA",
929
+ "projectId": 42,
930
+ "integrationId": 7
931
+ }
932
+ ```
933
+
934
+ - `externalSystem` — `IntegrationProvider` enum: `JIRA | GITHUB | AZURE_DEVOPS | SIMPLE_URL`. Internal-only issues (`Issue.integrationId IS NULL`) are NOT addressable here — agents reach those via `testplanit_issues_list`.
935
+ - `integrationId` (optional) — narrows to that integration directly and skips multi-match logic.
936
+
937
+ **Output (single match):**
938
+ ```json
939
+ {
940
+ "issue": {
941
+ "id": 99,
942
+ "externalKey": "JIRA-123",
943
+ "externalSystem": "JIRA",
944
+ "externalUrl": "https://acme.atlassian.net/browse/JIRA-123",
945
+ "externalStatus": "Open",
946
+ "summary": "Login fails on Safari",
947
+ "status": "open",
948
+ "projectId": 42,
949
+ "integration": { "id": 7, "name": "Acme Jira", "provider": "JIRA" },
950
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
951
+ "createdAt": "2026-01-01T00:00:00Z",
952
+ "lastSyncedAt": "2026-05-01T12:00:00Z",
953
+ "linkedCaseCount": 6
954
+ },
955
+ "multipleMatches": false
956
+ }
957
+ ```
958
+
959
+ **Output (multi-match fallback):** when `findMany` returns more than one row (rare — happens when two integrations of the same provider share an external key in the same project), the response shape switches:
960
+ ```json
961
+ {
962
+ "issues": [ /* up to 5 issues; same shape as `issue` above */ ],
963
+ "multipleMatches": true,
964
+ "hint": "Pass integrationId to disambiguate."
965
+ }
966
+ ```
967
+
968
+ #### `testplanit_issues_list` (Phase 8 — ISSUE-02)
969
+
970
+ List issues scoped to a project, with cursor-based pagination and deterministic `[{createdAt:'desc'},{id:'desc'}]` ordering. Each row carries `linkedCaseCount` inline (the dominant fan-out — median 6, p95 35, max 1061 in dev DB) so agents can rank issues by reach without a follow-up.
971
+
972
+ **Input:**
973
+ ```json
974
+ {
975
+ "projectId": 42,
976
+ "externalSystem": "JIRA",
977
+ "integrationId": 7,
978
+ "status": "open",
979
+ "externalStatus": "In Progress",
980
+ "cursor": 100,
981
+ "limit": 25
982
+ }
983
+ ```
984
+
985
+ > **Note:** an `assignee` filter is intentionally absent — `Issue` has no native assignee column; assignee data lives in `Issue.data: Json` (provider-shaped). Filtering on a JSON path would be brittle and per-provider; deferred until a real ask.
986
+
987
+ **Output:** `{ items, hasNextPage, nextCursor }` — items match the `issue` shape from `testplanit_issues_find_by_key`.
988
+
989
+ #### `testplanit_issues_get` (Phase 8 — ISSUE-03)
990
+
991
+ Fetch a single issue header plus three denormalized linked-row arrays inline. Mirrors Phase 7's D7-12 / D7-13 inline-with-truncation pattern.
992
+
993
+ **Input:**
994
+ ```json
995
+ { "issueId": 99 }
996
+ ```
997
+
998
+ **Output:**
999
+ ```json
1000
+ {
1001
+ "id": 99,
1002
+ "externalKey": "JIRA-123",
1003
+ "summary": "Login fails on Safari",
1004
+ "description": "Steps to repro: ...",
1005
+ "priority": "high",
1006
+ "issueTypeName": "Bug",
1007
+ "issueTypeIconUrl": "https://...",
1008
+ "note": "Reproduced on Safari 17.",
1009
+ "status": "open",
1010
+ "externalStatus": "In Progress",
1011
+ "linkedCases": [
1012
+ { "id": 7, "name": "Login flow", "source": "MANUAL", "automated": false, "latestResult": null }
1013
+ ],
1014
+ "linkedSessions": [
1015
+ { "id": 12, "name": "Login flow exploration", "mission": "Try edge cases.", "isCompleted": false, "state": { "id": 1, "name": "Draft" } }
1016
+ ],
1017
+ "linkedTestRuns": [
1018
+ { "id": 200, "name": "Smoke run", "isCompleted": true, "completedAt": "2026-05-01T12:00:00Z" }
1019
+ ],
1020
+ "truncated": { }
1021
+ }
1022
+ ```
1023
+
1024
+ > Each inline array is capped at 100 rows. When overflow occurs the response stamps `truncated.linkedCases: true` (or `linkedSessions` / `linkedTestRuns` as appropriate); the rest are reachable via `testplanit_cases_list({issueId})` and `testplanit_issues_list_links({issueId, target})`. `linkedSessionResults`, `linkedTestRunResults`, and `linkedTestRunStepResults` are NOT inlined (small junctions; agents reach them via the link tool).
1025
+
1026
+ #### `testplanit_issues_list_links` (Phase 8 — ISSUE-04)
1027
+
1028
+ Single dual-mode XOR tool covering all six `Issue` M:N junctions. Mirrors Phase 7's D7-11 dual-mode pattern (sessions findings).
1029
+
1030
+ - **Outbound mode** — given an `issueId` plus a `target`, returns the linked counterparts denormalized.
1031
+ - **Inbound mode** — given exactly one of `caseId | sessionId | sessionResultId | runId | runResultId | runStepResultId`, returns the linked Issue rows.
1032
+
1033
+ **Input (outbound):**
1034
+ ```json
1035
+ { "issueId": 99, "target": "cases", "cursor": 100, "limit": 25 }
1036
+ ```
1037
+
1038
+ - `target`: `cases | sessions | sessionResults | testRuns | testRunResults | testRunStepResults` (one of six).
1039
+
1040
+ **Input (inbound — exactly ONE inbound ID; the others MUST be omitted):**
1041
+ ```json
1042
+ { "caseId": 7 }
1043
+ ```
1044
+
1045
+ **Output:** `{ items, hasNextPage, nextCursor }` — items are typed per the queried direction.
1046
+
1047
+ > **Why one tool not six:** Phase 7's `sessions_findings_list` already proved the dual-mode shape composes cleanly. Six small tools would push the catalog past 30 and bury the mental model ("I'm walking the issue ↔ X graph") under ceremony.
1048
+
1049
+ #### `testplanit_issues_link`
1050
+
1051
+ Link one or more entities to an issue in a single call. Batch-capable: pass up to 100 entity IDs.
1052
+
1053
+ **Input:**
1054
+ ```json
1055
+ { "issueId": 7978, "entityType": "testCase", "entityIds": [201, 202, 203] }
1056
+ ```
1057
+
1058
+ - `entityType`: `testCase | session | testRun | testRunResult | testRunStepResult`
1059
+ - `entityIds`: array of 1–100 entity IDs
1060
+
1061
+ **Output:** `{ linked, issueId, entityType, entityIds }`
1062
+
1063
+ #### `testplanit_issues_unlink`
1064
+
1065
+ Remove links between entities and an issue. Same shape as `issues_link`.
1066
+
1067
+ **Input:**
1068
+ ```json
1069
+ { "issueId": 7978, "entityType": "testCase", "entityIds": [201] }
1070
+ ```
1071
+
1072
+ **Output:** `{ unlinked, issueId, entityType, entityIds }`
1073
+
1074
+ ### Repository Case Links
1075
+
1076
+ #### `testplanit_repository_case_links_list` (Phase 8 — REPO-05)
1077
+
1078
+ Traverse the manual-↔-imported case linkage graph. Three input modes (3-way XOR; exactly one ID supplied):
1079
+
1080
+ - `caseId` — bidirectional, matches both endpoints via `OR=[{caseAId},{caseBId}]`.
1081
+ - `caseAId` — one-way, originating side.
1082
+ - `caseBId` — one-way, destination side.
1083
+
1084
+ Optional `linkType` narrows to `SAME_TEST_DIFFERENT_SOURCE` or `DEPENDS_ON`. The response shape varies by mode: `caseId` collapses each row to an inline `otherCase` (the side opposite the queried id); `caseAId` / `caseBId` modes preserve both `caseA` and `caseB`.
1085
+
1086
+ **Input:**
1087
+ ```json
1088
+ { "caseId": 7, "linkType": "SAME_TEST_DIFFERENT_SOURCE", "cursor": 100, "limit": 25 }
1089
+ ```
1090
+
1091
+ **Output (caseId mode):**
1092
+ ```json
1093
+ {
1094
+ "items": [
1095
+ {
1096
+ "id": 31,
1097
+ "type": "SAME_TEST_DIFFERENT_SOURCE",
1098
+ "createdAt": "2026-01-01T00:00:00Z",
1099
+ "createdBy": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
1100
+ "otherCase": { "id": 9, "name": "automated_login_test", "source": "JUNIT", "automated": true }
1101
+ }
1102
+ ],
1103
+ "hasNextPage": false,
1104
+ "nextCursor": null
1105
+ }
1106
+ ```
1107
+
1108
+ > Project scope is enforced transitively by the host's access policy on `caseA.project` — `RepositoryCaseLink` itself is not project-scoped, so the tool deliberately exposes no project-id input.
1109
+
1110
+ ### Milestones
1111
+
1112
+ #### `testplanit_milestones_list`
1113
+
1114
+ List milestones scoped to a project, with **pooled `statusCounts` rollup** inline on every row (merged across linked test runs AND linked sessions). Single call answers *"How much work is left in milestone X."*
1115
+
1116
+ **Inputs:** `projectId` (required), `isCompleted?`, `isStarted?`, `milestoneTypeId?`, `createdById?` (single string), `from?` / `to?` (ISO 8601 createdAt range), `parentId?` (`null` = root-only, `number` = direct children of, omitted = all), `cursor?`, `limit?` (default 25, max 100).
1117
+
1118
+ **Each row carries:** `id`, `name`, `milestoneType: {id, name}`, `creator: {id, name, email}`, `parentId`, `directChildrenCount`, `commentCount`, `totalDescendants` (recursive CTE — counts the full subtree), `statusCounts: [{id, name, count}]`, `untested`, `total` (counts SUM to total), plus `isStarted`, `isCompleted`, `automaticCompletion`, `startedAt`, `completedAt`, `createdAt`.
1119
+
1120
+ **Cost model:** at most 5 backend round trips per page — one `milestones.findMany`, two batched `groupBy` calls (`testRunCases` + `sessionResults`; either skipped if the page has no runs or no sessions), one `status.findMany` for the status names, and one batched recursive-CTE call to `/api/mcp/milestones-descendants` for the page's `totalDescendants`. NEVER per-row.
1121
+
1122
+ #### `testplanit_milestones_get`
1123
+
1124
+ Fetch a single Milestone by id. Returns the full denormalized header + `note` and `docs` rendered to plain text (ProseMirror walked) + three inlined linked arrays:
1125
+
1126
+ - `linkedTestRuns` (cap **250** rows — wider than the standard 100 because milestones legitimately carry hundreds of runs; this is the dominant fan-out)
1127
+ - `linkedSessions` (cap 100)
1128
+ - `children` (cap 100, **1-level deep only**; each child carries `totalDescendants` so agents can prioritize which subtree to walk first)
1129
+
1130
+ When an array is over capacity the response carries `truncated.<key>: true`. The pooled `statusCounts` rollup at the milestone level is included; per-run rollups are reachable via `testplanit_test_runs_get`.
1131
+
1132
+ #### `testplanit_milestone_types_list`
1133
+
1134
+ List the milestone types assigned to a project (via the `MilestoneTypesAssignment` junction). Returns `{ items: [{id, name, isDefault}] }` ordered by name. No cursor pagination — types-per-project is small. Every `milestones_list` row + `milestones_get` response also denormalizes `milestoneType: {id, name}` inline, so this tool exists for full-catalog and filter-picker use cases.
1135
+
1136
+ #### `testplanit_milestones_create`
1137
+
1138
+ Create a new milestone. Use `testplanit_milestone_types_list` to enumerate valid `milestoneTypeId` values.
1139
+
1140
+ **Input:**
1141
+ ```json
1142
+ {
1143
+ "projectId": 1,
1144
+ "name": "v2.0 Release",
1145
+ "milestoneTypeId": 3,
1146
+ "parentId": 5,
1147
+ "note": "Target: end of Q3."
1148
+ }
1149
+ ```
1150
+
1151
+ - `milestoneTypeId` — required; use `testplanit_milestone_types_list` to enumerate.
1152
+ - `parentId` — optional; omit for a top-level milestone.
1153
+ - `note` — optional plain text.
1154
+
1155
+ **Output:**
1156
+ ```json
1157
+ {
1158
+ "id": 42,
1159
+ "name": "v2.0 Release",
1160
+ "isStarted": false,
1161
+ "isCompleted": false,
1162
+ "createdAt": "2026-05-07T00:00:00Z",
1163
+ "milestoneType": { "id": 3, "name": "Release" },
1164
+ "creator": { "id": "user-1", "name": "Alice", "email": "alice@example.com" },
1165
+ "parent": { "id": 5, "name": "Q3 Goals" },
1166
+ "note": "Target: end of Q3."
1167
+ }
1168
+ ```
1169
+
1170
+ #### `testplanit_milestones_update`
1171
+
1172
+ Update an existing milestone. Pass `note: null` or `parentId: null` to clear those fields. Setting `isStarted: true` records `startedAt`; setting `isCompleted: true` records `completedAt`.
1173
+
1174
+ **Input:**
1175
+ ```json
1176
+ {
1177
+ "milestoneId": 42,
1178
+ "name": "v2.0 Release (revised)",
1179
+ "note": null,
1180
+ "milestoneTypeId": 4,
1181
+ "parentId": null,
1182
+ "isStarted": true,
1183
+ "isCompleted": false
1184
+ }
1185
+ ```
1186
+
1187
+ **Output:** Same shape as `testplanit_milestones_create`.
1188
+
1189
+ ## Killer-app compositions (Phase 8)
1190
+
1191
+ ### Issue → linked test cases (2 calls)
1192
+
1193
+ ```json
1194
+ { "tool": "testplanit_issues_find_by_key",
1195
+ "input": { "externalKey": "JIRA-123", "externalSystem": "JIRA", "projectId": 42 } }
1196
+ // → { issue: { id: 99, ... }, multipleMatches: false }
1197
+
1198
+ { "tool": "testplanit_cases_list", "input": { "projectId": 42, "issueId": 99 } }
1199
+ // → { items: [{ id: 7, name: "Login flow", lastUpdatedAt: "...", latestResult: {...} }, ...] }
1200
+ ```
1201
+
1202
+ ### Stale automated tests (1 call)
1203
+
1204
+ ```json
1205
+ { "tool": "testplanit_cases_list",
1206
+ "input": { "projectId": 42, "automated": true, "staleSinceUpdate": true } }
1207
+ // → each row carries lastUpdatedAt + latestResult; envelope.truncated:true when scan cap (400) hit.
1208
+ ```
1209
+
1210
+ ### Recently-updated automated scripts (1 call)
1211
+
1212
+ ```json
1213
+ { "tool": "testplanit_cases_list",
1214
+ "input": { "projectId": 42, "automated": true, "updatedAfter": "2026-04-01T00:00:00Z" } }
1215
+ ```
1216
+
1217
+ ### Manual ↔ imported case graph walk (1 call)
1218
+
1219
+ ```json
1220
+ { "tool": "testplanit_repository_case_links_list",
1221
+ "input": { "caseId": 7, "linkType": "SAME_TEST_DIFFERENT_SOURCE" } }
1222
+ // → each row's otherCase carries the counterpart denormalized.
1223
+ ```
1224
+
1225
+ ### Never-run scripts (1 call)
1226
+
1227
+ ```json
1228
+ { "tool": "testplanit_cases_list",
1229
+ "input": { "projectId": 42, "automated": true, "hasNeverExecuted": true } }
1230
+ ```
1231
+
1232
+ ### Milestone progress overview (1 call)
1233
+
1234
+ `testplanit_milestones_list({ projectId, isCompleted: false })` returns every open milestone for a project with pooled `statusCounts + untested + total` inline (merged across linked test runs AND sessions). An agent answers *"How much work is left in each open milestone?"* in a single round trip without any follow-up `_get` calls.
1235
+
1236
+ ```json
1237
+ { "tool": "testplanit_milestones_list",
1238
+ "input": { "projectId": 42, "isCompleted": false } }
1239
+ // → each row: { name, statusCounts:[{name,count}], untested, total, totalDescendants, ... }
1240
+ ```
1241
+
1242
+ ### Create a run, add cases, and execute (3 calls)
1243
+
1244
+ Chain `testplanit_runs_create` → `testplanit_runs_cases_add` (if cases were not supplied at creation) → `testplanit_test_run_results_create` per case.
1245
+
1246
+ ```json
1247
+ { "tool": "testplanit_runs_create",
1248
+ "input": { "projectId": 42, "name": "JIRA-892 coverage run", "caseIds": [99, 100, 101] } }
1249
+ // → full run detail; total: 3, untested: 3
1250
+
1251
+ { "tool": "testplanit_runs_cases_add",
1252
+ "input": { "runId": 5, "caseIds": [102, 103] } }
1253
+ // → { runId: 5, requested: 2, total: 5 }
1254
+
1255
+ { "tool": "testplanit_test_run_results_create",
1256
+ "input": { "testRunCaseId": 100, "statusName": "Passed", "elapsed": 320 } }
1257
+ // → full result detail; attempt: 1, status: { name: "Passed" }
1258
+ ```
1259
+
1260
+ ## Soft-Delete Invariant
1261
+
1262
+ All TestPlanIt MCP "delete" tools perform soft-delete: they set `isDeleted: true` via PATCH update and never call the underlying ZenStack `delete` operation. Soft-deleted records remain in the database for audit purposes and are hidden from subsequent list/get tool calls.
1263
+
1264
+ ## Read-Only Tokens
1265
+
1266
+ Tokens minted with the `mode:read` scope are blocked at the host on POST/PATCH/DELETE — including all Phase 6 write tools. The MCP layer surfaces a structured error message naming the `mode:read` scope. See [Token scopes](#token-scopes) for minting steps.
1267
+
1268
+ ## Tool registry growth
1269
+
1270
+ The registry has grown additively over multiple releases:
1271
+
1272
+ - `whoami` — initial debug tool.
1273
+ - Cases / folders / tags / projects domain — 12 tools.
1274
+ - Test-run + session + findings read domain — 10 tools, plus an additive `issueId` filter on `testplanit_cases_list`.
1275
+ - Code-repositories / issues / repository-case-links read domain — 6 tools (`testplanit_code_repositories_list`, `testplanit_issues_find_by_key`, `testplanit_issues_list`, `testplanit_issues_get`, `testplanit_issues_list_links`, `testplanit_repository_case_links_list`), plus 7 maintenance filters + 2 row fields on `testplanit_cases_list` and inline `codeRepository` on `testplanit_cases_get`.
1276
+ - Milestones domain — 3 read tools (`testplanit_milestones_list`, `testplanit_milestones_get`, `testplanit_milestone_types_list`), plus 3 additive filters on `testplanit_cases_list` (`creatorIds`, `from`, `to`).
1277
+ - Issue link write domain — 2 write tools (`testplanit_issues_link`, `testplanit_issues_unlink`): batch-link / unlink any entity type to an issue in one call.
1278
+ - Run / session / milestone write domain — 8 write tools added: `testplanit_runs_create`, `testplanit_runs_update`, `testplanit_runs_cases_add`, `testplanit_test_run_results_create`, `testplanit_sessions_create`, `testplanit_sessions_update`, `testplanit_milestones_create`, `testplanit_milestones_update`.
1279
+
1280
+ Total registered tools: **42** (matches the count at the top of this catalog).
1281
+
1282
+ ## Claude Desktop configuration
1283
+
1284
+ Add the server to your `claude_desktop_config.json`:
1285
+
1286
+ ```json
1287
+ {
1288
+ "mcpServers": {
1289
+ "testplanit": {
1290
+ "command": "npx",
1291
+ "args": ["-y", "@testplanit/mcp-server"],
1292
+ "env": {
1293
+ "TESTPLANIT_API_TOKEN": "tpi_your_token_here",
1294
+ "TESTPLANIT_API_URL": "https://yourcompany.testplanit.com"
1295
+ }
1296
+ }
1297
+ }
1298
+ }
1299
+ ```
1300
+
1301
+ Restart Claude Desktop after editing the config. The TestPlanIt server should appear in the MCP servers list. Send a message asking Claude to "use the testplanit whoami tool" to verify the wiring.
1302
+
1303
+ ## Cursor configuration
1304
+
1305
+ Add the server to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project-scoped):
1306
+
1307
+ ```json
1308
+ {
1309
+ "mcpServers": {
1310
+ "testplanit": {
1311
+ "type": "stdio",
1312
+ "command": "npx",
1313
+ "args": ["-y", "@testplanit/mcp-server"],
1314
+ "env": {
1315
+ "TESTPLANIT_API_TOKEN": "tpi_your_token_here",
1316
+ "TESTPLANIT_API_URL": "https://yourcompany.testplanit.com"
1317
+ }
1318
+ }
1319
+ }
1320
+ }
1321
+ ```
1322
+
1323
+ Restart Cursor after editing. If you prefer to pull the token from your shell environment rather than hardcoding it, Cursor supports interpolation: `"TESTPLANIT_API_TOKEN": "${env:TESTPLANIT_API_TOKEN}"`.
1324
+
1325
+ ## Diagnostics
1326
+
1327
+ - All diagnostic output is written to **stderr**. Stdout is reserved for the JSON-RPC stream — never write to it.
1328
+ - On token-validation failure, a clear error is written to stderr and the process exits with code 1 **before** the agent expects a handshake response. Check the host client's MCP logs for the stderr text.
1329
+ - Token strings are redacted to the first 8 characters (`tpi_xxxx`) in any error message — the full secret is never logged.
1330
+
1331
+ ## Security notes
1332
+
1333
+ - Published with [npm provenance attestation](https://docs.npmjs.com/generating-provenance-statements). Verify the published artifact's chain of custody with:
1334
+ ```sh
1335
+ npm audit signatures @testplanit/mcp-server
1336
+ ```
1337
+ - The package's `publishConfig` locks `provenance: true` and `access: "public"` at the source. The release workflow in this repo declares `id-token: write` so npm can attest the build.
1338
+ - Read-only enforcement (`mode:read`) is verified end-to-end via Playwright in the host repo (`testplanit/e2e/tests/api-tokens/scopes.spec.ts`) — the chokepoint is shared with the REST API, so any client (browser, MCP, custom integration) hits the same gate.
1339
+
1340
+ ## Roadmap
1341
+
1342
+ Phase 6+ adds production tools for the test-case, execution, and repository domains. Each new tool plugs into the same registry pattern (`tools/index.ts → registerAll`) without touching `server.ts`. The error-mapping seam (`mapHttpErrorToToolResult`) is already shared, so write tools that hit a `READ_ONLY_TOKEN` 403 surface the same friendly message Phase 5's `whoami` would.
1343
+
1344
+ ## License
1345
+
1346
+ MIT