diff --git a/.gitignore b/.gitignore index ded2245..a343d96 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ package-lock.json *.tsbuildinfo .idea/ .superpowers/ +.worktrees/ .vscode/ Thumbs.db Desktop.ini diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index ca7c281..bd30ea4 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # Relay MCP Tool Contracts -Issue #26 implements the five safe capture/read handlers from this version `1` contract. Tool discovery exposes strict input schemas: malformed request shapes (including unknown, forbidden, missing, or out-of-range fields) receive SDK-native MCP `InvalidParams`. Schema-valid tool execution returns structured `{ schemaVersion: 1, data, warnings }`; execution errors use `VALIDATION_ERROR`, `NOT_FOUND`, `STORAGE_ERROR`, or `INTERNAL_ERROR` without stack traces, SQLite details, secrets, or local paths. Compact text is a compatibility supplement, never a parsing requirement. +Issue #21 adds five intent-specific mutation handlers to the version `1` contract alongside the issue #26 capture/read handlers. Tool discovery exposes strict input schemas: malformed request shapes (including unknown, forbidden, missing, or out-of-range fields) receive SDK-native MCP `InvalidParams` (`-32602`) before application execution. Schema-valid tool execution returns structured `{ schemaVersion: 1, data, warnings }`; execution errors use stable Relay codes without stack traces, SQLite details, secrets, or local paths. Compact text is a compatibility supplement, never a parsing requirement. ## `task_capture` @@ -26,24 +26,34 @@ Input: required valid `sessionId` and `limit` from 1 through 100. Output: `{ ses Input: task ID plus one or more editable task fields or explicit clear flags. MCP `null` is rejected; explicit `clear*` flags are the only clear operation, and a value cannot accompany its matching flag. Output: `{ task, change }`, including `NO_CHANGE` for an approved no-op. `sessionId`, provenance, status, and lifecycle timestamps are never editable. +`change` is `{ action: "EDITED" | "NO_CHANGE", fields }`. `fields` lists only persisted editable fields that changed, in stable order: `title`, `description`, `priority`, `workspace`, `sourceContext`. + +The editable fields are `title`, `description`, `priority`, `workspace`, and `sourceContext`. Nullable fields are cleared only with `clearDescription`, `clearPriority`, `clearWorkspace`, or `clearSourceContext`; direct `null` and value-plus-clear requests are invalid params. A normalized value that is already persisted returns `change: { action: "NO_CHANGE", fields: [] }` without a persistence write. + ## `task_triage` Input: task ID and target `INBOX`, `ACTIVE`, or `BACKLOG`. Output: `{ task, change }`. `IN_PROGRESS`, `DONE`, and `ARCHIVED` have their own intent-specific tools. +`change` is `{ action: "TRIAGED" | "NO_CHANGE", from, to }`; `from` and `to` are the persisted source and result statuses. + ## `task_start` -Input: task ID. Output: `{ task, change }`. It performs only the focused start lifecycle operation. +Input: task ID. Output: `{ task, change: { action: "STARTED" | "NO_CHANGE" } }`. It performs only the focused start lifecycle operation. ## `task_complete` -Input: task ID. Output: `{ task, change }`. It performs only the focused completion lifecycle operation. +Input: task ID. Output: `{ task, change: { action: "COMPLETED" | "NO_CHANGE" } }`. It performs only the focused completion lifecycle operation. ## `task_archive` -Input: task ID. Output: `{ task, change }`. It performs only the focused archive lifecycle operation. +Input: task ID. Output: `{ task, change: { action: "ARCHIVED" | "NO_CHANGE" } }`. It performs only the focused archive lifecycle operation. + +All lifecycle mutations return `change.action = "NO_CHANGE"` when the focused operation leaves the persisted task unchanged, without changing timestamps or writing the repository. Invalid lifecycle transitions use `CONFLICT`; attempting a restricted mutation of an archived task uses `ARCHIVED_TASK`. Other schema-valid execution failures map to `VALIDATION_ERROR`, `NOT_FOUND`, `STORAGE_ERROR`, or generic `INTERNAL_ERROR`; internal messages are never exposed. ## Mutation safety and versioning Invoke `task_edit`, `task_triage`, `task_start`, `task_complete`, and `task_archive` only after explicit user direction in the active conversation. Relay validates data and lifecycle legality but cannot authenticate conversational intent under the OS-user trust boundary, so it intentionally has no fake `confirmed`, `requestedBy`, or copied-user-text field. +The MCP SDK owns schema-invalid input handling (`InvalidParams`, `-32602`). Relay owns errors after a schema-valid request reaches the application and returns the structured versioned error envelope. Each mutation invokes one focused application operation; MCP does not read SQLite or implement lifecycle legality. + There is no `task_update`, `task_set_status`, generic CRUD mutation, or unrestricted lifecycle command. Tool names are not version-prefixed; a breaking change requires a new integer schema version and an explicit compatibility decision. Later issues implement every handler through shared application services; MCP never reads SQLite directly. diff --git a/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations-review-fixes.md b/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations-review-fixes.md new file mode 100644 index 0000000..9e3fa62 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations-review-fixes.md @@ -0,0 +1,198 @@ +# Issue #21 MCP Mutation Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring PR #30 in line with the detailed review follow-up at issuecomment-5087087958 while keeping the scope limited to issue #21's five intent-specific MCP mutation tools. + +**Architecture:** Keep MCP adapters responsible for strict input validation, one focused application-operation invocation, and result/error mapping. The application layer remains authoritative for lifecycle legality and returns atomic before/after mutation results so metadata does not require a second read. + +**Tech Stack:** TypeScript, Zod, `@modelcontextprotocol/sdk`, Vitest, pnpm, MCP stdio transport. + +## Global Constraints + +- Do not add CLI work, skills, vendor integrations, auth, bulk mutation, restore/reopen, permanent deletion, or persistence redesign. +- Preserve public MCP output shapes and contract schema version. +- `task_edit`, `task_triage`, `task_start`, `task_complete`, and `task_archive` remain the only mutation tools. +- Do not weaken or delete tests to make the suite pass. +- Schema-invalid requests must remain SDK-native invalid-params failures; valid requests that fail during execution must use Relay structured errors. + +--- + +### Task 1: Complete the mutation schema test matrix + +**Files:** + +- Modify: `tests/unit/interfaces/mcp/create-mcp-server.test.ts` +- Add a focused schema test under `tests/unit/interfaces/mcp/` only if the server test becomes unreadable. + +**Required checks:** + +- [x] `task_edit` rejects `{ taskId }` without an editable field. +- [x] `task_edit` rejects unknown fields and each forbidden/immutable field: `status`, `createdByType`, `createdByName`, `sessionId`, `createdAt`, `updatedAt`, `startedAt`, `completedAt`, `archivedAt`, `confirmed`, and `requestedBy`. +- [x] `task_edit` rejects direct `null` for nullable fields where clearing requires a clear flag. +- [x] `task_edit` rejects value-plus-clear conflicts for `description`, `priority`, `workspace`, and `sourceContext`. +- [x] `task_triage` accepts only `INBOX`, `ACTIVE`, and `BACKLOG`, and rejects `IN_PROGRESS`, `DONE`, and `ARCHIVED`. +- [x] `task_start`, `task_complete`, and `task_archive` reject unknown fields. +- [x] Schema-invalid calls are verified through actual `client.callTool(...)` behavior and remain SDK-native invalid-params responses. + +### Task 2: Cover every editable field, clear operation, and stable ordering + +**Files:** + +- Modify: `tests/unit/interfaces/mcp/create-mcp-server.test.ts` + +**Required checks:** + +- [x] Test successful edits for `title`, `description`, `priority`, `workspace`, and `sourceContext`. +- [x] Test successful clears for `clearDescription`, `clearPriority`, `clearWorkspace`, and `clearSourceContext`. +- [x] For each mutation, assert the complete returned task, `change.action === 'EDITED'`, and only the persisted field in `change.fields`. +- [x] Fetch the task afterward with `task_get` and compare it with the mutation result. +- [x] Change `sourceContext`, `title`, and `priority` in request order and assert stable metadata order `['title', 'priority', 'sourceContext']`. + +### Task 3: Complete approved no-op coverage + +**Files:** + +- Modify: `tests/unit/interfaces/mcp/create-mcp-server.test.ts` +- Inspect: domain lifecycle implementation and existing lifecycle tests before deciding archive behavior. + +**Required checks:** + +- [x] `task_edit` setting a normalized current value returns success with `NO_CHANGE` and `fields: []`. +- [x] `task_edit` clearing an already-null field returns success with `NO_CHANGE` and `fields: []`. +- [x] `task_triage` to the task's current `INBOX`, `ACTIVE`, or `BACKLOG` status returns `NO_CHANGE` with equal `from` and `to`. +- [x] Starting an `IN_PROGRESS` task and completing a `DONE` task return successful `NO_CHANGE` results. +- [x] Verify authoritative archive behavior; preserve the current same-target archived no-op if domain tests confirm it. +- [x] Assert timestamps remain unchanged and no repository update occurs for no-ops. + +### Task 4: Complete structured execution-error mapping coverage + +**Files:** + +- Modify: `tests/unit/interfaces/mcp/create-mcp-server.test.ts` +- Modify fixtures under `tests/unit/application/tasks/` only when a controlled repository/application double is needed. + +**Required checks:** + +- [x] Add a schema-valid request producing `VALIDATION_ERROR`. +- [x] Add a missing-task mutation producing `NOT_FOUND`. +- [x] Add invalid lifecycle transitions producing `CONFLICT`, including more than one intent where useful. +- [x] Add archived edit, triage, start, and complete cases producing `ARCHIVED_TASK`; test archive as either restricted or no-op according to the authoritative domain contract. +- [x] Add a repository update failure producing `STORAGE_ERROR` without leaking SQL, paths, internal messages, stack, or causes. +- [x] Add an unexpected controlled exception producing generic `INTERNAL_ERROR` without exposing the original exception message. + +### Task 5: Prove each handler invokes exactly one focused application operation + +**Files:** + +- Modify: `tests/unit/interfaces/mcp/` focused registration/handler tests. +- Modify: `src/interfaces/mcp/tools/*.ts` only if a wiring defect is found. + +**Required checks:** + +- [x] Use a narrow `TaskApplication` fake/spy to prove `task_edit` calls only edit. +- [x] Prove `task_triage` maps `INBOX` to move-to-inbox, `ACTIVE` to activate, and `BACKLOG` to move-to-backlog. +- [x] Prove `task_start`, `task_complete`, and `task_archive` each call only their focused operation. +- [x] Prove handlers do not access repositories, fetch separately before mutation, call multiple lifecycle methods, or implement lifecycle legality. + +### Task 6: Extract one shared MCP output-envelope schema helper + +**Files:** + +- Create: `src/interfaces/mcp/schemas/mcp-output-schema.ts` +- Modify: `src/interfaces/mcp/schemas/read-tool-schemas.ts` +- Modify: `src/interfaces/mcp/schemas/mutation-tool-schemas.ts` +- Update import-only tests if required. + +**Required implementation:** + +- [x] Add `createMcpOutputSchema(data: T)` that builds the strict `{ schemaVersion, data, warnings }` envelope using the existing contract version and warning schema. +- [x] Make read/capture and mutation schemas use this one helper. +- [x] Preserve public output shapes, contract version, read/capture behavior, and inferred types. + +### Task 7: Simplify the duplicated application mutation API + +**Files:** + +- Modify: `src/application/tasks/task-application.ts` +- Modify: `src/application/tasks/use-cases/edit-task.ts` +- Modify: `src/application/tasks/use-cases/transition-task.ts` +- Modify direct callers and affected tests only. + +**Required implementation:** + +- [x] Choose one canonical mutation API returning `{ before: Task; task: Task }` for edit, move-to-inbox, activate, move-to-backlog, start, complete, and archive. +- [x] Update existing callers that need only the result to use `.task` at their boundary. +- [x] Preserve atomic before/result capture without duplicate repository reads. +- [x] Keep lifecycle rules in the domain/application layer and avoid unrelated refactoring. +- [x] Add or update tests proving existing HTTP/UI behavior remains unchanged and no mutation rules are duplicated. + +### Task 8: Preserve existing tools and stdio protocol cleanliness + +**Files:** + +- Modify: `tests/integration/mcp-stdio.test.ts` +- Modify existing MCP unit tests only as needed. + +**Required checks:** + +- [x] Existing tools remain discoverable and unchanged: `task_capture`, `task_list`, `task_get`, `task_find_similar`, and `session_captures_list`. +- [x] Exactly the five approved mutation tools are present. +- [x] Generic tools such as `task_update`, `task_set_status`, and unrestricted mutation variants remain absent. +- [x] Built stdio integration performs capture, edit, triage or start, complete or archive, and final readback. +- [x] Assert stdout contains only MCP protocol output and no logs, stack traces, SQL details, or debug output. + +### Task 9: Update documentation and PR evidence + +**Files:** + +- Modify: `docs/mcp-tools.md` +- Update PR #30 description only after final verification evidence is available. + +**Required checks:** + +- [x] Document exact input names (`taskId`, `target`, and clear flags), editable fields, direct-null rejection, clear behavior, deterministic field order, triage restrictions, result shapes, no-ops, archived/conflict behavior, explicit-user-direction precondition, absence of fake confirmation fields, and SDK-invalid-params versus Relay execution-error behavior. +- [x] Run the full verification list and record command-by-command evidence from the final local code state; repository-wide `pnpm verify` remains blocked before later stages by unrelated pre-existing formatter failures. +- [x] Inspect `git diff --stat main...HEAD` and `git diff main...HEAD` for accidental scope expansion. +- [x] Record local test counts, stdout cleanliness, absence of generic mutation tools, and unchanged read/capture tools. +- [ ] Update PR #30's description with final evidence after the verified local changes are intentionally committed and pushed. + +## Final verification evidence + +- `pnpm test`: PASS — 25 files, 371 tests. +- `pnpm test:coverage`: PASS — 88.76% statements, 81.34% branches, 88.99% functions, 90.92% lines. +- `pnpm typecheck`: PASS. +- Changed-file Prettier check: PASS. +- Changed-file ESLint check: PASS. +- `pnpm build`: PASS. +- `pnpm validate:assets`: PASS. +- `pnpm verify`: BLOCKED at `pnpm format:check` by 16 unrelated pre-existing files outside this follow-up's changed set; no unrelated formatting sweep was applied. +- `pnpm audit --audit-level high`: BLOCKED by the environment's denied npm registry network request; no audit result was produced. +- Remote PR #30 CI at existing head `2732e70f315ead1a9678369e031b290d37b75d2c`: `verify` and CodeRabbit SUCCESS. Local changes are not yet represented by that remote SHA, so the PR description and re-review request remain intentionally untouched. + +## Verification commands + +```bash +pnpm test -- tests/unit/interfaces/mcp +pnpm test -- tests/integration/mcp-stdio.test.ts +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test:coverage +pnpm build +pnpm validate:assets +pnpm verify +``` + +## Human review checkpoints + +- [ ] Every mutation schema rejects unknown, immutable, provenance, session, and timestamp fields. +- [ ] `task_triage` cannot reach `IN_PROGRESS`, `DONE`, or `ARCHIVED`. +- [ ] No fake conversational confirmation field exists. +- [ ] Every handler calls one focused application operation. +- [ ] Before/after metadata is captured without duplicate reads. +- [ ] Approved no-ops return success with deterministic metadata and no unnecessary writes. +- [ ] Archived restrictions match the domain lifecycle contract. +- [ ] Read/capture tools and envelopes have no unintended changes. +- [ ] MCP stdout is protocol-clean. +- [ ] `pnpm verify` passes on the final commit. diff --git a/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations.md b/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations.md new file mode 100644 index 0000000..ffa74aa --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-issue-21-mcp-mutations.md @@ -0,0 +1,33 @@ +# Issue #21 MCP Mutation Tools — Implementation Plan + +**Goal:** Add the five user-directed, intent-specific MCP mutation tools while reusing the shared runtime, MCP envelopes, error mapping, DTO mapping, and task application service established by issue #26. + +**Architecture:** The MCP interface receives strict Zod-validated input, invokes exactly one focused `TaskApplication` operation, and returns the existing versioned MCP success envelope with a full task DTO and deterministic change metadata. The adapter never reads SQLite or accepts generic status/provenance/session/timestamp mutation. + +## Files and responsibilities + +- `src/interfaces/contracts/task-contract.ts`: define mutation-specific input and output contract shapes, including clear directives and detailed change metadata. +- `src/interfaces/mcp/schemas/mutation-tool-schemas.ts`: re-export mutation schemas and wrap result schemas in the standard MCP output envelope. +- `src/interfaces/mcp/mapping/change-metadata.ts`: compare pre/post tasks in a stable field order and produce edit/triage/lifecycle `NO_CHANGE` metadata. +- `src/interfaces/mcp/tools/task-{edit,triage,start,complete,archive}.ts`: one focused handler per intent. +- `src/interfaces/mcp/tools/register-mutation-tools.ts`: compose those five registrations. +- `src/interfaces/mcp/create-mcp-server.ts`: add mutation registration next to the #26 read/capture registrations. +- `src/interfaces/mcp/mapping/mcp-errors.ts`: distinguish domain transition and archived-task errors with the stable #19 MCP codes. +- `tests/unit/interfaces/mcp/create-mcp-server.test.ts`: prove discovery, strict schemas, outputs, no-ops, lifecycle/error behavior, and absence of generic mutation capabilities. +- `tests/integration/mcp-stdio.test.ts`: prove mutation works in the built stdio process while stdout remains protocol-clean. +- `docs/mcp-tools.md`: document explicit-user-direction precondition and every mutation contract. + +## Execution sequence + +1. Add strict-schema and pure metadata tests; run them red. +2. Add shared mutation schemas, deterministic metadata, and stable conflict/archive error mapping. +3. Implement edit and restricted triage with success, clear, no-op, and error tests. +4. Implement start, complete, and archive as separate focused handlers; prove generic mutation tools are absent. +5. Extend built stdio coverage, update documentation, then run all required checks including `pnpm verify`. + +## Review checklist + +- Every mutation calls one focused `TaskApplication` method; none touches SQLite. +- No input accepts `confirmed`, authorization prose, provenance, session, status outside triage's three targets, or timestamps. +- All no-ops return `NO_CHANGE` successfully with deterministic metadata. +- Existing read/capture tools remain registered and unchanged. diff --git a/eslint.config.js b/eslint.config.js index 151ab12..562de33 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks'; /** @type {import('eslint').Linter.Config[]} */ const config = [ { - ignores: ['dist/**', 'coverage/**', 'node_modules/**'], + ignores: ['dist/**', 'coverage/**', 'node_modules/**', '.worktrees/**'], }, { files: ['**/*.ts', '**/*.tsx', '**/*.js'], diff --git a/src/application/tasks/task-application.ts b/src/application/tasks/task-application.ts index b1c15e5..c5a6646 100644 --- a/src/application/tasks/task-application.ts +++ b/src/application/tasks/task-application.ts @@ -29,17 +29,21 @@ export interface TaskApplicationDependencies { readonly clock?: Clock; readonly idGenerator?: IdGenerator; } +export interface TaskMutationResult { + readonly before: Task; + readonly task: Task; +} export interface TaskApplication { create(input: CreateTaskInput): Task; get(input: GetTaskInput): Task; list(input: ListTasksInput): readonly Task[]; - edit(input: EditTaskInput): Task; - moveToInbox(input: TaskIdInput): Task; - activate(input: TaskIdInput): Task; - start(input: TaskIdInput): Task; - moveToBacklog(input: TaskIdInput): Task; - complete(input: TaskIdInput): Task; - archive(input: TaskIdInput): Task; + edit(input: EditTaskInput): TaskMutationResult; + moveToInbox(input: TaskIdInput): TaskMutationResult; + activate(input: TaskIdInput): TaskMutationResult; + start(input: TaskIdInput): TaskMutationResult; + moveToBacklog(input: TaskIdInput): TaskMutationResult; + complete(input: TaskIdInput): TaskMutationResult; + archive(input: TaskIdInput): TaskMutationResult; listSessionCaptures(input: ListSessionCapturesInput): readonly Task[]; findSimilar(input: FindSimilarTasksInput): readonly Task[]; } @@ -49,8 +53,10 @@ export function createTaskApplication(dependencies: TaskApplicationDependencies) clock: dependencies.clock ?? new SystemClock(), idGenerator: dependencies.idGenerator ?? new UuidGenerator(), }; - const transition = (input: TaskIdInput, operation: (task: Task, now: string) => Task) => - transitionTaskUseCase(input, resolvedDependencies, operation); + const transition = ( + input: TaskIdInput, + operation: (task: Task, now: string) => Task, + ): TaskMutationResult => transitionTaskUseCase(input, resolvedDependencies, operation); return { create: (input) => createTaskUseCase(input, resolvedDependencies), get: (input) => getTaskUseCase(input, resolvedDependencies.repository), diff --git a/src/application/tasks/use-cases/edit-task.ts b/src/application/tasks/use-cases/edit-task.ts index acd9ed7..4321a11 100644 --- a/src/application/tasks/use-cases/edit-task.ts +++ b/src/application/tasks/use-cases/edit-task.ts @@ -14,10 +14,14 @@ export interface EditTaskInput { readonly workspace?: string | null; readonly sourceContext?: string | null; } +export interface EditTaskResult { + readonly before: Task; + readonly task: Task; +} export function editTaskUseCase( input: EditTaskInput, dependencies: { readonly repository: TaskRepository; readonly clock: Clock }, -): Task { +): EditTaskResult { const changes: TaskChanges = { ...(input.title === undefined ? {} : { title: input.title }), ...(input.description === undefined ? {} : { description: input.description }), @@ -30,7 +34,14 @@ export function editTaskUseCase( const id = normalizeTaskId(input.id); const existing = required(() => dependencies.repository.findById(id), id); const updated = editTask(existing, changes, dependencies.clock.now().toISOString()); - return updated === existing - ? existing - : persist(() => dependencies.repository.update(updated), `Task ${id} could not be updated.`); + return { + before: existing, + task: + updated === existing + ? existing + : persist( + () => dependencies.repository.update(updated), + `Task ${id} could not be updated.`, + ), + }; } diff --git a/src/application/tasks/use-cases/transition-task.ts b/src/application/tasks/use-cases/transition-task.ts index 344a894..003c20c 100644 --- a/src/application/tasks/use-cases/transition-task.ts +++ b/src/application/tasks/use-cases/transition-task.ts @@ -5,15 +5,26 @@ import { normalizeTaskId, type TaskIdInput } from './get-task.js'; import { persist, required } from './repository-operations.js'; export type TaskTransition = (task: Task, now: string) => Task; +export interface TaskTransitionResult { + readonly before: Task; + readonly task: Task; +} export function transitionTaskUseCase( input: TaskIdInput, dependencies: { readonly repository: TaskRepository; readonly clock: Clock }, transition: TaskTransition, -): Task { +): TaskTransitionResult { const id = normalizeTaskId(input.id); const existing = required(() => dependencies.repository.findById(id), id); const updated = transition(existing, dependencies.clock.now().toISOString()); - return updated === existing - ? existing - : persist(() => dependencies.repository.update(updated), `Task ${id} could not be updated.`); + return { + before: existing, + task: + updated === existing + ? existing + : persist( + () => dependencies.repository.update(updated), + `Task ${id} could not be updated.`, + ), + }; } diff --git a/src/interfaces/contracts/task-contract.ts b/src/interfaces/contracts/task-contract.ts index d48f76e..8c4e4ad 100644 --- a/src/interfaces/contracts/task-contract.ts +++ b/src/interfaces/contracts/task-contract.ts @@ -6,6 +6,13 @@ const optionalText = (maximum: number) => nullableText(maximum).optional(); const optionalEditableText = (maximum: number) => z.string().trim().min(1).max(maximum).optional(); const taskStatusSchema = z.enum(['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED']); const taskPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH']); +const editableTaskFieldSchema = z.enum([ + 'title', + 'description', + 'priority', + 'workspace', + 'sourceContext', +]); export const taskIdSchema = z.string().trim().min(1).max(100); @@ -160,8 +167,58 @@ export const sessionCapturesResultSchema = z count: z.number().int().nonnegative(), }) .strict(); -export const taskEditResultSchema = taskResultSchema(['EDITED', 'NO_CHANGE']); -export const taskTriageResultSchema = taskResultSchema(['TRIAGED', 'NO_CHANGE']); +export const taskEditResultSchema = z + .object({ + task: taskDtoSchema, + change: z.discriminatedUnion('action', [ + z + .object({ + action: z.literal('EDITED'), + fields: z + .array(editableTaskFieldSchema) + .min(1) + .max(5) + .refine((fields) => new Set(fields).size === fields.length, { + message: 'fields must not contain duplicates', + }), + }) + .strict(), + z + .object({ + action: z.literal('NO_CHANGE'), + fields: z.array(editableTaskFieldSchema).length(0), + }) + .strict(), + ]), + }) + .strict(); +export const taskTriageResultSchema = z + .object({ + task: taskDtoSchema, + change: z.discriminatedUnion('action', [ + z + .object({ + action: z.literal('TRIAGED'), + from: taskStatusSchema, + to: taskStatusSchema, + }) + .strict() + .refine((change) => change.from !== change.to, { + message: 'TRIAGED status values must differ', + }), + z + .object({ + action: z.literal('NO_CHANGE'), + from: taskStatusSchema, + to: taskStatusSchema, + }) + .strict() + .refine((change) => change.from === change.to, { + message: 'NO_CHANGE status values must match', + }), + ]), + }) + .strict(); export const taskStartResultSchema = taskResultSchema(['STARTED', 'NO_CHANGE']); export const taskCompleteResultSchema = taskResultSchema(['COMPLETED', 'NO_CHANGE']); export const taskArchiveResultSchema = taskResultSchema(['ARCHIVED', 'NO_CHANGE']); diff --git a/src/interfaces/http/task-routes.ts b/src/interfaces/http/task-routes.ts index 22c81b7..019db6d 100644 --- a/src/interfaces/http/task-routes.ts +++ b/src/interfaces/http/task-routes.ts @@ -55,7 +55,7 @@ export async function routeTaskRequest( if (request.method !== 'POST') sendMethodNotAllowed(response, 'POST'); else { await requireEmptyBody(request); - sendJson(response, 200, { task: toTaskDto(application[action]({ id })) }); + sendJson(response, 200, { task: toTaskDto(application[action]({ id }).task) }); } return true; } @@ -64,7 +64,7 @@ export async function routeTaskRequest( else if (request.method === 'PATCH') { const changes = editTaskSchema.parse(await readJsonBody(request)); sendJson(response, 200, { - task: toTaskDto(application.edit({ id, ...optionalFields(changes) })), + task: toTaskDto(application.edit({ id, ...optionalFields(changes) }).task), }); } else sendMethodNotAllowed(response, 'GET, PATCH'); return true; diff --git a/src/interfaces/mcp/create-mcp-server.ts b/src/interfaces/mcp/create-mcp-server.ts index be7c6b4..d735f49 100644 --- a/src/interfaces/mcp/create-mcp-server.ts +++ b/src/interfaces/mcp/create-mcp-server.ts @@ -4,6 +4,7 @@ import { getPackageMetadata } from '../../shared/package-metadata.js'; import type { TaskApplication } from '../../application/tasks/task-application.js'; import { registerReadTools } from './tools/register-read-tools.js'; import { registerTaskCaptureTool } from './tools/task-capture.js'; +import { registerMutationTools } from './tools/register-mutation-tools.js'; export function createMcpServer(taskApplication: TaskApplication): McpServer { const meta = getPackageMetadata(); @@ -13,6 +14,7 @@ export function createMcpServer(taskApplication: TaskApplication): McpServer { }); registerReadTools(server, taskApplication); registerTaskCaptureTool(server, taskApplication); + registerMutationTools(server, taskApplication); server.tool('relay_health', 'Return health status of the local Relay service', {}, async () => { const health = getHealth(); diff --git a/src/interfaces/mcp/mapping/change-metadata.ts b/src/interfaces/mcp/mapping/change-metadata.ts new file mode 100644 index 0000000..4c3bf69 --- /dev/null +++ b/src/interfaces/mcp/mapping/change-metadata.ts @@ -0,0 +1,24 @@ +import type { Task } from '../../../domain/task/task.js'; + +const EDITABLE_FIELDS = ['title', 'description', 'priority', 'workspace', 'sourceContext'] as const; + +export function editChange(before: Task, after: Task) { + const fields = EDITABLE_FIELDS.filter((field) => before[field] !== after[field]); + return { action: fields.length === 0 ? ('NO_CHANGE' as const) : ('EDITED' as const), fields }; +} + +export function triageChange(before: Task, after: Task) { + return { + action: before.status === after.status ? ('NO_CHANGE' as const) : ('TRIAGED' as const), + from: before.status, + to: after.status, + }; +} + +export function lifecycleChange( + before: Task, + after: Task, + action: Action, +) { + return { action: before.status === after.status ? ('NO_CHANGE' as const) : action }; +} diff --git a/src/interfaces/mcp/mapping/mcp-errors.ts b/src/interfaces/mcp/mapping/mcp-errors.ts index bf56749..f3c0519 100644 --- a/src/interfaces/mcp/mapping/mcp-errors.ts +++ b/src/interfaces/mcp/mapping/mcp-errors.ts @@ -4,10 +4,17 @@ import { TaskNotFoundError, TaskPersistenceError, } from '../../../application/tasks/task-application-errors.js'; -import { TaskDomainError } from '../../../domain/task/task-errors.js'; +import { + TaskArchivedError, + TaskDomainError, + TaskTransitionError, +} from '../../../domain/task/task-errors.js'; import { mcpError } from './mcp-result.js'; export function toMcpError(error: unknown) { + if (error instanceof TaskArchivedError) return mcpError('ARCHIVED_TASK', 'The task is archived.'); + if (error instanceof TaskTransitionError) + return mcpError('CONFLICT', 'Task lifecycle transition is not allowed.'); if ( error instanceof ZodError || error instanceof InvalidTaskRequestError || diff --git a/src/interfaces/mcp/schemas/mcp-output-schema.ts b/src/interfaces/mcp/schemas/mcp-output-schema.ts new file mode 100644 index 0000000..f82ce8a --- /dev/null +++ b/src/interfaces/mcp/schemas/mcp-output-schema.ts @@ -0,0 +1,13 @@ +import { CONTRACT_SCHEMA_VERSION } from '../../contracts/contract-version.js'; +import { warningSchema } from '../../contracts/warning-contract.js'; +import { z } from 'zod'; + +export function createMcpOutputSchema(data: T) { + return z + .object({ + schemaVersion: z.literal(CONTRACT_SCHEMA_VERSION), + data, + warnings: z.array(warningSchema), + }) + .strict(); +} diff --git a/src/interfaces/mcp/schemas/mutation-tool-schemas.ts b/src/interfaces/mcp/schemas/mutation-tool-schemas.ts new file mode 100644 index 0000000..420c5a5 --- /dev/null +++ b/src/interfaces/mcp/schemas/mutation-tool-schemas.ts @@ -0,0 +1,26 @@ +import { + taskArchiveInputSchema, + taskArchiveResultSchema, + taskCompleteInputSchema, + taskCompleteResultSchema, + taskEditInputSchema, + taskEditResultSchema, + taskStartInputSchema, + taskStartResultSchema, + taskTriageInputSchema, + taskTriageResultSchema, +} from '../../contracts/task-contract.js'; +import { createMcpOutputSchema } from './mcp-output-schema.js'; + +export { + taskArchiveInputSchema, + taskCompleteInputSchema, + taskEditInputSchema, + taskStartInputSchema, + taskTriageInputSchema, +}; +export const taskArchiveOutputSchema = createMcpOutputSchema(taskArchiveResultSchema); +export const taskCompleteOutputSchema = createMcpOutputSchema(taskCompleteResultSchema); +export const taskEditOutputSchema = createMcpOutputSchema(taskEditResultSchema); +export const taskStartOutputSchema = createMcpOutputSchema(taskStartResultSchema); +export const taskTriageOutputSchema = createMcpOutputSchema(taskTriageResultSchema); diff --git a/src/interfaces/mcp/schemas/read-tool-schemas.ts b/src/interfaces/mcp/schemas/read-tool-schemas.ts index 169c2ea..1995697 100644 --- a/src/interfaces/mcp/schemas/read-tool-schemas.ts +++ b/src/interfaces/mcp/schemas/read-tool-schemas.ts @@ -12,24 +12,13 @@ import { taskGetResultSchema, taskListResultSchema, } from '../../contracts/task-contract.js'; -import { CONTRACT_SCHEMA_VERSION } from '../../contracts/contract-version.js'; -import { warningSchema } from '../../contracts/warning-contract.js'; -import { z } from 'zod'; +import { createMcpOutputSchema } from './mcp-output-schema.js'; -const outputSchema = (data: z.ZodType) => - z - .object({ - schemaVersion: z.literal(CONTRACT_SCHEMA_VERSION), - data, - warnings: z.array(warningSchema), - }) - .strict(); - -export const taskCaptureOutputSchema = outputSchema(taskCaptureResultSchema); -export const taskListOutputSchema = outputSchema(taskListResultSchema); -export const taskGetOutputSchema = outputSchema(taskGetResultSchema); -export const taskFindSimilarOutputSchema = outputSchema(taskFindSimilarResultSchema); -export const sessionCapturesOutputSchema = outputSchema(sessionCapturesResultSchema); +export const taskCaptureOutputSchema = createMcpOutputSchema(taskCaptureResultSchema); +export const taskListOutputSchema = createMcpOutputSchema(taskListResultSchema); +export const taskGetOutputSchema = createMcpOutputSchema(taskGetResultSchema); +export const taskFindSimilarOutputSchema = createMcpOutputSchema(taskFindSimilarResultSchema); +export const sessionCapturesOutputSchema = createMcpOutputSchema(sessionCapturesResultSchema); export { agentCaptureInputSchema, diff --git a/src/interfaces/mcp/tools/register-mutation-tools.ts b/src/interfaces/mcp/tools/register-mutation-tools.ts new file mode 100644 index 0000000..b076d13 --- /dev/null +++ b/src/interfaces/mcp/tools/register-mutation-tools.ts @@ -0,0 +1,15 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { registerTaskArchiveTool } from './task-archive.js'; +import { registerTaskCompleteTool } from './task-complete.js'; +import { registerTaskEditTool } from './task-edit.js'; +import { registerTaskStartTool } from './task-start.js'; +import { registerTaskTriageTool } from './task-triage.js'; + +export function registerMutationTools(server: McpServer, application: TaskApplication): void { + registerTaskEditTool(server, application); + registerTaskTriageTool(server, application); + registerTaskStartTool(server, application); + registerTaskCompleteTool(server, application); + registerTaskArchiveTool(server, application); +} diff --git a/src/interfaces/mcp/tools/task-archive.ts b/src/interfaces/mcp/tools/task-archive.ts new file mode 100644 index 0000000..04afb5c --- /dev/null +++ b/src/interfaces/mcp/tools/task-archive.ts @@ -0,0 +1,32 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { lifecycleChange } from '../mapping/change-metadata.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { + taskArchiveInputSchema, + taskArchiveOutputSchema, +} from '../schemas/mutation-tool-schemas.js'; + +export function registerTaskArchiveTool(server: McpServer, application: TaskApplication): void { + server.registerTool( + 'task_archive', + { + description: 'Archive a task only after explicit user direction in the active conversation', + inputSchema: taskArchiveInputSchema, + outputSchema: taskArchiveOutputSchema, + }, + async (input) => { + try { + const mutation = application.archive({ id: input.taskId }); + return mcpSuccess({ + task: toTaskMcpDto(mutation.task), + change: lifecycleChange(mutation.before, mutation.task, 'ARCHIVED'), + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/src/interfaces/mcp/tools/task-complete.ts b/src/interfaces/mcp/tools/task-complete.ts new file mode 100644 index 0000000..807c042 --- /dev/null +++ b/src/interfaces/mcp/tools/task-complete.ts @@ -0,0 +1,32 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { lifecycleChange } from '../mapping/change-metadata.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { + taskCompleteInputSchema, + taskCompleteOutputSchema, +} from '../schemas/mutation-tool-schemas.js'; + +export function registerTaskCompleteTool(server: McpServer, application: TaskApplication): void { + server.registerTool( + 'task_complete', + { + description: 'Complete a task only after explicit user direction in the active conversation', + inputSchema: taskCompleteInputSchema, + outputSchema: taskCompleteOutputSchema, + }, + async (input) => { + try { + const mutation = await application.complete({ id: input.taskId }); + return mcpSuccess({ + task: toTaskMcpDto(mutation.task), + change: lifecycleChange(mutation.before, mutation.task, 'COMPLETED'), + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/src/interfaces/mcp/tools/task-edit.ts b/src/interfaces/mcp/tools/task-edit.ts new file mode 100644 index 0000000..76c613d --- /dev/null +++ b/src/interfaces/mcp/tools/task-edit.ts @@ -0,0 +1,43 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { editChange } from '../mapping/change-metadata.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { taskEditInputSchema, taskEditOutputSchema } from '../schemas/mutation-tool-schemas.js'; + +const fieldUpdate = ( + field: K, + value: V | undefined, + clear: boolean | undefined, +) => (value === undefined ? (clear === true ? { [field]: null } : {}) : { [field]: value }); + +export function registerTaskEditTool(server: McpServer, application: TaskApplication): void { + server.registerTool( + 'task_edit', + { + description: + 'Edit task metadata only after explicit user direction in the active conversation', + inputSchema: taskEditInputSchema, + outputSchema: taskEditOutputSchema, + }, + async (input) => { + try { + const mutation = await application.edit({ + id: input.taskId, + ...(input.title === undefined ? {} : { title: input.title }), + ...fieldUpdate('description', input.description, input.clearDescription), + ...fieldUpdate('priority', input.priority, input.clearPriority), + ...fieldUpdate('workspace', input.workspace, input.clearWorkspace), + ...fieldUpdate('sourceContext', input.sourceContext, input.clearSourceContext), + }); + return mcpSuccess({ + task: toTaskMcpDto(mutation.task), + change: editChange(mutation.before, mutation.task), + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/src/interfaces/mcp/tools/task-start.ts b/src/interfaces/mcp/tools/task-start.ts new file mode 100644 index 0000000..571499b --- /dev/null +++ b/src/interfaces/mcp/tools/task-start.ts @@ -0,0 +1,29 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { lifecycleChange } from '../mapping/change-metadata.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { taskStartInputSchema, taskStartOutputSchema } from '../schemas/mutation-tool-schemas.js'; + +export function registerTaskStartTool(server: McpServer, application: TaskApplication): void { + server.registerTool( + 'task_start', + { + description: 'Start a task only after explicit user direction in the active conversation', + inputSchema: taskStartInputSchema, + outputSchema: taskStartOutputSchema, + }, + async (input) => { + try { + const mutation = await application.start({ id: input.taskId }); + return mcpSuccess({ + task: toTaskMcpDto(mutation.task), + change: lifecycleChange(mutation.before, mutation.task, 'STARTED'), + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/src/interfaces/mcp/tools/task-triage.ts b/src/interfaces/mcp/tools/task-triage.ts new file mode 100644 index 0000000..9abf4c1 --- /dev/null +++ b/src/interfaces/mcp/tools/task-triage.ts @@ -0,0 +1,34 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { triageChange } from '../mapping/change-metadata.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { taskTriageInputSchema, taskTriageOutputSchema } from '../schemas/mutation-tool-schemas.js'; + +export function registerTaskTriageTool(server: McpServer, application: TaskApplication): void { + server.registerTool( + 'task_triage', + { + description: 'Triage a task only after explicit user direction in the active conversation', + inputSchema: taskTriageInputSchema, + outputSchema: taskTriageOutputSchema, + }, + async (input) => { + try { + const mutation = + input.target === 'INBOX' + ? application.moveToInbox({ id: input.taskId }) + : input.target === 'ACTIVE' + ? application.activate({ id: input.taskId }) + : application.moveToBacklog({ id: input.taskId }); + return mcpSuccess({ + task: toTaskMcpDto(mutation.task), + change: triageChange(mutation.before, mutation.task), + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/tests/integration/mcp-stdio.test.ts b/tests/integration/mcp-stdio.test.ts index 958c54a..e2fc7f2 100644 --- a/tests/integration/mcp-stdio.test.ts +++ b/tests/integration/mcp-stdio.test.ts @@ -38,8 +38,33 @@ describe('mcp-stdio integration', () => { 'task_get', 'task_find_similar', 'session_captures_list', + 'task_edit', + 'task_triage', + 'task_start', + 'task_complete', + 'task_archive', ]), ); + const mutationTools = tools.tools + .map((tool) => tool.name) + .filter((name) => + ['task_edit', 'task_triage', 'task_start', 'task_complete', 'task_archive'].includes( + name, + ), + ); + expect(mutationTools).toHaveLength(5); + expect(mutationTools).toEqual( + expect.arrayContaining([ + 'task_edit', + 'task_triage', + 'task_start', + 'task_complete', + 'task_archive', + ]), + ); + expect(tools.tools.map((tool) => tool.name)).not.toEqual( + expect.arrayContaining(['task_update', 'task_set_status']), + ); const res = (await client.callTool({ name: 'relay_health', arguments: {} })) as { content: Array<{ type: string; text: string }>; @@ -75,6 +100,61 @@ describe('mcp-stdio integration', () => { ); expect(otherSession.structuredContent?.data?.task?.sessionId).toBe('stdio-session-b'); + const firstTaskId = first.structuredContent?.data?.task?.id; + expect(firstTaskId).toBeDefined(); + const edited = (await client.callTool({ + name: 'task_edit', + arguments: { taskId: firstTaskId, title: 'Edited MCP capture' }, + })) as { + structuredContent?: { data?: { task?: { title?: string }; change?: { action?: string } } }; + }; + expect(edited.structuredContent?.data).toMatchObject({ + task: { title: 'Edited MCP capture' }, + change: { action: 'EDITED' }, + }); + const triaged = (await client.callTool({ + name: 'task_triage', + arguments: { taskId: firstTaskId, target: 'ACTIVE' }, + })) as { + structuredContent?: { data?: { task?: { status?: string }; change?: { action?: string } } }; + }; + expect(triaged.structuredContent?.data).toMatchObject({ + task: { status: 'ACTIVE' }, + change: { action: 'TRIAGED' }, + }); + const started = (await client.callTool({ + name: 'task_start', + arguments: { taskId: firstTaskId }, + })) as { + structuredContent?: { data?: { task?: { status?: string }; change?: { action?: string } } }; + }; + expect(started.structuredContent?.data).toMatchObject({ + task: { status: 'IN_PROGRESS' }, + change: { action: 'STARTED' }, + }); + const completed = (await client.callTool({ + name: 'task_complete', + arguments: { taskId: firstTaskId }, + })) as { + structuredContent?: { data?: { task?: { status?: string }; change?: { action?: string } } }; + }; + expect(completed.structuredContent?.data).toMatchObject({ + task: { status: 'DONE' }, + change: { action: 'COMPLETED' }, + }); + const finalTask = (await client.callTool({ + name: 'task_get', + arguments: { taskId: firstTaskId }, + })) as { + structuredContent?: { + data?: { task?: { title?: string; status?: string } }; + }; + }; + expect(finalTask.structuredContent?.data?.task).toMatchObject({ + title: 'Edited MCP capture', + status: 'DONE', + }); + const sessionA = (await client.callTool({ name: 'session_captures_list', arguments: { sessionId: 'stdio-session-a' }, diff --git a/tests/unit/application/tasks/task-application.test.ts b/tests/unit/application/tasks/task-application.test.ts index 7e347de..b203eed 100644 --- a/tests/unit/application/tasks/task-application.test.ts +++ b/tests/unit/application/tasks/task-application.test.ts @@ -252,18 +252,22 @@ describe('TaskApplication', () => { sourceContext: null, }), ).toMatchObject({ - title: 'Changed', - description: null, - priority: null, - workspace: null, - sourceContext: null, - updatedAt: NOW.toISOString(), + before: original, + task: { + title: 'Changed', + description: null, + priority: null, + workspace: null, + sourceContext: null, + updatedAt: NOW.toISOString(), + }, }); expect(original.title).toBe('Task'); expect(repository.updateCalls).toBe(1); expect(clock.calls).toBe(1); expect(application.edit({ id: original.id, title: 'Changed' })).toMatchObject({ - title: 'Changed', + before: expect.objectContaining({ title: 'Changed' }), + task: expect.objectContaining({ title: 'Changed' }), }); expect(repository.updateCalls).toBe(1); expect(() => application.edit({ id: original.id })).toThrow(InvalidTaskRequestError); @@ -280,8 +284,11 @@ describe('TaskApplication', () => { const { application, repository } = setup(); repository.tasks.set(source.id, source); expect(application[method]({ id: source.id })).toMatchObject({ - status, - updatedAt: NOW.toISOString(), + before: source, + task: { + status, + updatedAt: NOW.toISOString(), + }, }); expect(repository.updateCalls).toBe(1); }); @@ -290,7 +297,7 @@ describe('TaskApplication', () => { const { application, repository } = setup(); const original = task(); repository.tasks.set('task-1', original); - expect(application.moveToInbox({ id: 'task-1' })).toBe(original); + expect(application.moveToInbox({ id: 'task-1' }).task).toBe(original); expect(repository.updateCalls).toBe(0); expect(() => application.start({ id: 'task-1' })).toThrow(TaskTransitionError); }); diff --git a/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts b/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts index 2e0949d..4990c79 100644 --- a/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts +++ b/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts @@ -16,11 +16,13 @@ import { taskCompleteInputSchema, taskDtoSchema, taskEditInputSchema, + taskEditResultSchema, taskGetInputSchema, taskListInputSchema, taskStartInputSchema, taskStartResultSchema, taskTriageInputSchema, + taskTriageResultSchema, } from '../../../../src/interfaces/contracts/task-contract.js'; import { parseSessionId, @@ -256,4 +258,49 @@ describe('agent integration contracts', () => { message: 'Task was not found.', }); }); + + it('requires consistent edit and triage change metadata', () => { + expect( + taskEditResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'EDITED', fields: [] }, + }).success, + ).toBe(false); + expect( + taskEditResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'EDITED', fields: ['title', 'title'] }, + }).success, + ).toBe(false); + expect( + taskEditResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'NO_CHANGE', fields: ['title'] }, + }).success, + ).toBe(false); + expect( + taskTriageResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'TRIAGED', from: 'INBOX', to: 'INBOX' }, + }).success, + ).toBe(false); + expect( + taskTriageResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'NO_CHANGE', from: 'INBOX', to: 'ACTIVE' }, + }).success, + ).toBe(false); + expect( + taskEditResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'EDITED', fields: ['title'] }, + }).success, + ).toBe(true); + expect( + taskTriageResultSchema.safeParse({ + task: VALID_TASK, + change: { action: 'TRIAGED', from: 'INBOX', to: 'ACTIVE' }, + }).success, + ).toBe(true); + }); }); diff --git a/tests/unit/interfaces/mcp/create-mcp-server.test.ts b/tests/unit/interfaces/mcp/create-mcp-server.test.ts index d59297b..64cc935 100644 --- a/tests/unit/interfaces/mcp/create-mcp-server.test.ts +++ b/tests/unit/interfaces/mcp/create-mcp-server.test.ts @@ -1,27 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createMcpServer } from '../../../../src/interfaces/mcp/create-mcp-server.js'; import { createTaskApplication } from '../../../../src/application/tasks/task-application.js'; import { InMemoryTaskRepository } from '../../application/tasks/task-test-fixtures.js'; - -async function connectMcp(server: ReturnType) { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ name: 'test-client', version: '1.0.0' }); - let closed = false; - const close = async () => { - if (closed) return; - closed = true; - await Promise.allSettled([client.close(), server.close()]); - }; - try { - await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); - return { client, close }; - } catch (error) { - await close(); - throw error; - } -} +import { TaskRepositoryError } from '../../../../src/application/tasks/task-repository-errors.js'; +import { connectMcp } from './mcp-test-utils.js'; describe('createMcpServer', () => { it('exposes relay_health tool via in-memory transport', async () => { @@ -72,6 +54,45 @@ describe('createMcpServer', () => { } }); + it('exposes only the five intent-specific user-directed mutation tools', async () => { + const server = createMcpServer( + createTaskApplication({ repository: new InMemoryTaskRepository() }), + ); + const { client, close } = await connectMcp(server); + try { + const tools = await client.listTools(); + const byName = new Map(tools.tools.map((tool) => [tool.name, tool])); + + expect([...byName.keys()].sort()).toEqual( + [ + 'relay_health', + 'task_capture', + 'task_list', + 'task_get', + 'task_find_similar', + 'session_captures_list', + 'task_edit', + 'task_triage', + 'task_start', + 'task_complete', + 'task_archive', + ].sort(), + ); + expect(byName.get('task_edit')?.inputSchema).toMatchObject({ + additionalProperties: false, + required: ['taskId'], + }); + expect(byName.get('task_edit')?.inputSchema.properties).not.toHaveProperty('status'); + expect(byName.get('task_edit')?.inputSchema.properties).not.toHaveProperty('sessionId'); + expect(byName.get('task_edit')?.inputSchema.properties).not.toHaveProperty('createdAt'); + expect(byName.get('task_triage')?.inputSchema.properties?.target).toMatchObject({ + enum: ['INBOX', 'ACTIVE', 'BACKLOG'], + }); + } finally { + await close(); + } + }); + it('advertises the strict capture and bounded read-tool contracts', async () => { const server = createMcpServer( createTaskApplication({ repository: new InMemoryTaskRepository() }), @@ -165,4 +186,169 @@ describe('createMcpServer', () => { await close(); } }); + + it('edits and transitions a task through focused mutation tools with deterministic metadata', async () => { + const server = createMcpServer( + createTaskApplication({ repository: new InMemoryTaskRepository() }), + ); + const { client, close } = await connectMcp(server); + try { + const capture = (await client.callTool({ + name: 'task_capture', + arguments: { title: 'Mutate safely', createdByName: 'Codex', sessionId: 'session-21' }, + })) as unknown as { structuredContent: { data: { task: { id: string } } } }; + const taskId = capture.structuredContent.data.task.id; + + expect( + (await client.callTool({ + name: 'task_edit', + arguments: { taskId, title: 'Mutated safely', description: 'Draft description' }, + })) as unknown as { + structuredContent: { data: { task: { title: string }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { + data: { + task: { title: 'Mutated safely' }, + change: { action: 'EDITED', fields: ['title', 'description'] }, + }, + }, + }); + expect( + (await client.callTool({ + name: 'task_edit', + arguments: { taskId, title: 'Mutated safely' }, + })) as unknown as { + structuredContent: { data: { change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { data: { change: { action: 'NO_CHANGE', fields: [] } } }, + }); + expect( + (await client.callTool({ + name: 'task_edit', + arguments: { taskId, clearDescription: true }, + })) as unknown as { + structuredContent: { data: { task: { description: null }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { + data: { + task: { description: null }, + change: { action: 'EDITED', fields: ['description'] }, + }, + }, + }); + + expect( + (await client.callTool({ + name: 'task_triage', + arguments: { taskId, target: 'ACTIVE' }, + })) as unknown as { + structuredContent: { data: { task: { status: string }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { + data: { + task: { status: 'ACTIVE' }, + change: { action: 'TRIAGED', from: 'INBOX', to: 'ACTIVE' }, + }, + }, + }); + expect( + (await client.callTool({ name: 'task_start', arguments: { taskId } })) as unknown as { + structuredContent: { data: { task: { status: string }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { + data: { task: { status: 'IN_PROGRESS' }, change: { action: 'STARTED' } }, + }, + }); + expect( + (await client.callTool({ name: 'task_start', arguments: { taskId } })) as unknown as { + structuredContent: { data: { change: unknown } }; + }, + ).toMatchObject({ structuredContent: { data: { change: { action: 'NO_CHANGE' } } } }); + expect( + (await client.callTool({ name: 'task_complete', arguments: { taskId } })) as unknown as { + structuredContent: { data: { task: { status: string }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { data: { task: { status: 'DONE' }, change: { action: 'COMPLETED' } } }, + }); + expect( + (await client.callTool({ name: 'task_archive', arguments: { taskId } })) as unknown as { + structuredContent: { data: { task: { status: string }; change: unknown } }; + }, + ).toMatchObject({ + structuredContent: { + data: { task: { status: 'ARCHIVED' }, change: { action: 'ARCHIVED' } }, + }, + }); + + const archivedEdit = (await client.callTool({ + name: 'task_edit', + arguments: { taskId, title: 'No longer mutable' }, + })) as unknown as { isError?: boolean; structuredContent: { error: { code: string } } }; + expect(archivedEdit).toMatchObject({ + isError: true, + structuredContent: { error: { code: 'ARCHIVED_TASK' } }, + }); + } finally { + await close(); + } + }); + + it('maps mutation validation, conflict, missing, and storage failures without leaking internals', async () => { + const repository = new InMemoryTaskRepository(); + const server = createMcpServer(createTaskApplication({ repository })); + const { client, close } = await connectMcp(server); + try { + const capture = (await client.callTool({ + name: 'task_capture', + arguments: { title: 'Error mapping', createdByName: 'Codex', sessionId: 'session-errors' }, + })) as unknown as { structuredContent: { data: { task: { id: string } } } }; + const taskId = capture.structuredContent.data.task.id; + + const invalid = await client.callTool({ + name: 'task_edit', + arguments: { taskId, title: 'Invalid', status: 'DONE' }, + }); + expect(invalid).toMatchObject({ isError: true }); + + const conflict = (await client.callTool({ + name: 'task_start', + arguments: { taskId }, + })) as unknown as { + isError: boolean; + structuredContent: { error: { code: string; message: string } }; + }; + expect(conflict).toMatchObject({ + isError: true, + structuredContent: { + error: { code: 'CONFLICT', message: 'Task lifecycle transition is not allowed.' }, + }, + }); + + const missing = (await client.callTool({ + name: 'task_archive', + arguments: { taskId: 'missing' }, + })) as unknown as { structuredContent: { error: { code: string } } }; + expect(missing).toMatchObject({ structuredContent: { error: { code: 'NOT_FOUND' } } }); + + repository.updateFailure = new TaskRepositoryError('database path must remain private'); + const storage = (await client.callTool({ + name: 'task_triage', + arguments: { taskId, target: 'ACTIVE' }, + })) as unknown as { structuredContent: { error: { code: string; message: string } } }; + expect(storage).toMatchObject({ + structuredContent: { + error: { code: 'STORAGE_ERROR', message: 'Task storage operation failed.' }, + }, + }); + expect(JSON.stringify(storage)).not.toContain('database path must remain private'); + } finally { + await close(); + } + }); }); diff --git a/tests/unit/interfaces/mcp/mcp-test-utils.ts b/tests/unit/interfaces/mcp/mcp-test-utils.ts new file mode 100644 index 0000000..b0b11b2 --- /dev/null +++ b/tests/unit/interfaces/mcp/mcp-test-utils.ts @@ -0,0 +1,21 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { createMcpServer } from '../../../../src/interfaces/mcp/create-mcp-server.js'; + +export async function connectMcp(server: ReturnType) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + let closed = false; + const close = async () => { + if (closed) return; + closed = true; + await Promise.allSettled([client.close(), server.close()]); + }; + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { client, close }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/tests/unit/interfaces/mcp/mutation-tool-handlers.test.ts b/tests/unit/interfaces/mcp/mutation-tool-handlers.test.ts new file mode 100644 index 0000000..66c6d6e --- /dev/null +++ b/tests/unit/interfaces/mcp/mutation-tool-handlers.test.ts @@ -0,0 +1,480 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createTaskApplication, + type TaskApplication, + type TaskMutationResult, +} from '../../../../src/application/tasks/task-application.js'; +import { + InvalidTaskRequestError, + TaskNotFoundError, + TaskPersistenceError, +} from '../../../../src/application/tasks/task-application-errors.js'; +import type { Task } from '../../../../src/domain/task/task.js'; +import { TaskArchivedError, TaskTransitionError } from '../../../../src/domain/task/task-errors.js'; +import { createMcpServer } from '../../../../src/interfaces/mcp/create-mcp-server.js'; +import { InMemoryTaskRepository } from '../../application/tasks/task-test-fixtures.js'; +import { connectMcp } from './mcp-test-utils.js'; + +function task(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: 'Prepare release', + description: null, + status: 'INBOX', + priority: null, + workspace: 'relay', + sourceContext: null, + createdByType: 'AGENT', + createdByName: 'Codex', + sessionId: 'session-a', + createdAt: '2026-07-26T10:00:00.000Z', + updatedAt: '2026-07-26T10:00:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, + ...overrides, + }; +} + +function mutation(before: Task, taskResult: Task = before): TaskMutationResult { + return { before, task: taskResult }; +} + +function readMethods(result: Task) { + return { + create: vi.fn(() => result), + get: vi.fn(() => result), + list: vi.fn(() => [result]), + findSimilar: vi.fn(() => []), + listSessionCaptures: vi.fn(() => [result]), + }; +} + +describe('MCP mutation handlers', () => { + it('calls exactly one canonical application mutation method per tool', async () => { + const before = task(); + const calls = { + edit: vi.fn(() => mutation(before)), + moveToInbox: vi.fn(() => mutation(before)), + activate: vi.fn(() => mutation(before, task({ status: 'ACTIVE' }))), + moveToBacklog: vi.fn(() => mutation(before, task({ status: 'BACKLOG' }))), + start: vi.fn(() => mutation(before, task({ status: 'IN_PROGRESS' }))), + complete: vi.fn(() => mutation(before, task({ status: 'DONE' }))), + archive: vi.fn(() => mutation(before, task({ status: 'ARCHIVED' }))), + }; + const application = { + ...readMethods(before), + ...calls, + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + for (const [name, arguments_] of [ + ['task_edit', { taskId: before.id, title: 'Updated' }], + ['task_triage', { taskId: before.id, target: 'INBOX' }], + ['task_start', { taskId: before.id }], + ['task_complete', { taskId: before.id }], + ['task_archive', { taskId: before.id }], + ] as const) { + const result = await client.callTool({ name, arguments: arguments_ }); + expect(result).not.toHaveProperty('isError', true); + } + + expect(calls.edit).toHaveBeenCalledWith({ id: before.id, title: 'Updated' }); + expect(calls.moveToInbox).toHaveBeenCalledWith({ id: before.id }); + expect(calls.start).toHaveBeenCalledWith({ id: before.id }); + expect(calls.complete).toHaveBeenCalledWith({ id: before.id }); + expect(calls.archive).toHaveBeenCalledWith({ id: before.id }); + expect(calls.activate).not.toHaveBeenCalled(); + expect(calls.moveToBacklog).not.toHaveBeenCalled(); + + await client.callTool({ + name: 'task_triage', + arguments: { taskId: before.id, target: 'ACTIVE' }, + }); + await client.callTool({ + name: 'task_triage', + arguments: { taskId: before.id, target: 'BACKLOG' }, + }); + expect(calls.activate).toHaveBeenCalledWith({ id: before.id }); + expect(calls.moveToBacklog).toHaveBeenCalledWith({ id: before.id }); + } finally { + await close(); + } + }); + + it.each([ + ['missing editable field', {}], + ['direct description null', { description: null }], + ['direct priority null', { priority: null }], + ['direct workspace null', { workspace: null }], + ['direct sourceContext null', { sourceContext: null }], + ['description clear conflict', { description: 'text', clearDescription: true }], + ['priority clear conflict', { priority: 'HIGH', clearPriority: true }], + ['workspace clear conflict', { workspace: 'relay', clearWorkspace: true }], + ['sourceContext clear conflict', { sourceContext: 'issue', clearSourceContext: true }], + ] as const)('rejects task_edit %s as SDK invalid params', async (_name, extra) => { + const result = task(); + const application = { + ...readMethods(result), + edit: vi.fn(() => mutation(result)), + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const response = await client.callTool({ + name: 'task_edit', + arguments: { taskId: result.id, ...extra }, + }); + expect(response).toMatchObject({ isError: true }); + expect(JSON.stringify(response)).toContain('-32602'); + expect(application.edit).not.toHaveBeenCalled(); + } finally { + await close(); + } + }); + + it.each([ + ['status', { status: 'DONE' }], + ['createdByType', { createdByType: 'HUMAN' }], + ['createdByName', { createdByName: 'Alice' }], + ['sessionId', { sessionId: 'session-b' }], + ['createdAt', { createdAt: '2026-07-27T10:00:00.000Z' }], + ['updatedAt', { updatedAt: '2026-07-27T10:00:00.000Z' }], + ['startedAt', { startedAt: '2026-07-27T10:00:00.000Z' }], + ['completedAt', { completedAt: '2026-07-27T10:00:00.000Z' }], + ['archivedAt', { archivedAt: '2026-07-27T10:00:00.000Z' }], + ['confirmed', { confirmed: true }], + ['requestedBy', { requestedBy: 'human' }], + ] as const)( + 'rejects forbidden task_edit field %s as SDK invalid params', + async (_field, extra) => { + const result = task(); + const application = { + ...readMethods(result), + edit: vi.fn(() => mutation(result)), + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const response = await client.callTool({ + name: 'task_edit', + arguments: { taskId: result.id, title: 'Updated', ...extra }, + }); + expect(response).toMatchObject({ isError: true }); + expect(JSON.stringify(response)).toContain('-32602'); + expect(application.edit).not.toHaveBeenCalled(); + } finally { + await close(); + } + }, + ); + + it('edits every field, clears nullable fields, preserves field order, and reads back the result', async () => { + const repository = new InMemoryTaskRepository(); + const application = createTaskApplication({ repository }); + const created = application.create({ + title: 'Original title', + description: 'Original description', + priority: 'LOW', + workspace: 'relay', + sourceContext: 'original context', + creator: { type: 'AGENT', name: 'Codex' }, + sessionId: 'session-a', + }); + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const edited = await client.callTool({ + name: 'task_edit', + arguments: { + taskId: created.id, + sourceContext: 'new context', + title: 'New title', + priority: 'HIGH', + }, + }); + expect(edited).toMatchObject({ + structuredContent: { + data: { + task: { title: 'New title', priority: 'HIGH', sourceContext: 'new context' }, + change: { action: 'EDITED', fields: ['title', 'priority', 'sourceContext'] }, + }, + }, + }); + + for (const [field, value, expected] of [ + ['title', 'Final title', { title: 'Final title' }], + ['description', 'Final description', { description: 'Final description' }], + ['priority', 'NORMAL', { priority: 'NORMAL' }], + ['workspace', 'final-workspace', { workspace: 'final-workspace' }], + ['sourceContext', 'final-context', { sourceContext: 'final-context' }], + ] as const) { + const response = await client.callTool({ + name: 'task_edit', + arguments: { taskId: created.id, [field]: value }, + }); + expect(response).toMatchObject({ + structuredContent: { + data: { task: expected, change: { action: 'EDITED', fields: [field] } }, + }, + }); + } + + for (const [clearField, field] of [ + ['clearDescription', 'description'], + ['clearPriority', 'priority'], + ['clearWorkspace', 'workspace'], + ['clearSourceContext', 'sourceContext'], + ] as const) { + const response = await client.callTool({ + name: 'task_edit', + arguments: { taskId: created.id, [clearField]: true }, + }); + expect(response).toMatchObject({ + structuredContent: { + data: { task: { [field]: null }, change: { action: 'EDITED', fields: [field] } }, + }, + }); + const readBack = await client.callTool({ + name: 'task_get', + arguments: { taskId: created.id }, + }); + expect(readBack).toMatchObject({ + structuredContent: { data: { task: { id: created.id, [field]: null } } }, + }); + } + } finally { + await close(); + } + }); + + it('returns successful no-op metadata without changing timestamps or persisting', async () => { + const repository = new InMemoryTaskRepository(); + const application = createTaskApplication({ repository }); + const created = application.create({ + title: 'No-op task', + description: null, + priority: null, + workspace: null, + sourceContext: null, + creator: { type: 'AGENT', name: 'Codex' }, + sessionId: 'session-no-op', + }); + const originalUpdatedAt = created.updatedAt; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const edit = await client.callTool({ + name: 'task_edit', + arguments: { taskId: created.id, title: created.title, clearDescription: true }, + }); + expect(edit).toMatchObject({ + structuredContent: { data: { change: { action: 'NO_CHANGE', fields: [] } } }, + }); + const triage = await client.callTool({ + name: 'task_triage', + arguments: { taskId: created.id, target: 'INBOX' }, + }); + expect(triage).toMatchObject({ + structuredContent: { + data: { change: { action: 'NO_CHANGE', from: 'INBOX', to: 'INBOX' } }, + }, + }); + const afterEditAndTriageUpdatedAt = repository.tasks.get(created.id)?.updatedAt; + expect(afterEditAndTriageUpdatedAt).toBe(originalUpdatedAt); + + await client.callTool({ + name: 'task_triage', + arguments: { taskId: created.id, target: 'ACTIVE' }, + }); + const started = await client.callTool({ + name: 'task_start', + arguments: { taskId: created.id }, + }); + expect(started).toMatchObject({ + structuredContent: { data: { change: { action: 'STARTED' } } }, + }); + const afterStartUpdatedAt = repository.tasks.get(created.id)?.updatedAt; + const startedAgain = await client.callTool({ + name: 'task_start', + arguments: { taskId: created.id }, + }); + expect(startedAgain).toMatchObject({ + structuredContent: { data: { change: { action: 'NO_CHANGE' } } }, + }); + expect(repository.tasks.get(created.id)?.updatedAt).toBe(afterStartUpdatedAt); + await client.callTool({ name: 'task_complete', arguments: { taskId: created.id } }); + const afterCompleteUpdatedAt = repository.tasks.get(created.id)?.updatedAt; + const completedAgain = await client.callTool({ + name: 'task_complete', + arguments: { taskId: created.id }, + }); + expect(completedAgain).toMatchObject({ + structuredContent: { data: { change: { action: 'NO_CHANGE' } } }, + }); + expect(repository.tasks.get(created.id)?.updatedAt).toBe(afterCompleteUpdatedAt); + await client.callTool({ name: 'task_archive', arguments: { taskId: created.id } }); + const afterArchiveUpdatedAt = repository.tasks.get(created.id)?.updatedAt; + const archivedAgain = await client.callTool({ + name: 'task_archive', + arguments: { taskId: created.id }, + }); + expect(archivedAgain).toMatchObject({ + structuredContent: { data: { change: { action: 'NO_CHANGE' } } }, + }); + expect(repository.tasks.get(created.id)?.updatedAt).toBe(afterArchiveUpdatedAt); + + expect(repository.updateCalls).toBe(4); + expect((edit as { isError?: boolean }).isError).not.toBe(true); + expect((triage as { isError?: boolean }).isError).not.toBe(true); + expect((startedAgain as { isError?: boolean }).isError).not.toBe(true); + expect((completedAgain as { isError?: boolean }).isError).not.toBe(true); + expect((archivedAgain as { isError?: boolean }).isError).not.toBe(true); + } finally { + await close(); + } + }); + + it.each([ + [ + 'validation', + 'task_edit', + { taskId: 'task-1', title: 'Updated' }, + new InvalidTaskRequestError('invalid'), + 'VALIDATION_ERROR', + ], + [ + 'not found', + 'task_archive', + { taskId: 'missing' }, + new TaskNotFoundError('missing'), + 'NOT_FOUND', + ], + [ + 'conflict', + 'task_start', + { taskId: 'task-1' }, + new TaskTransitionError('wrong state'), + 'CONFLICT', + ], + [ + 'archived edit', + 'task_edit', + { taskId: 'task-1', title: 'Updated' }, + new TaskArchivedError('archived internal detail'), + 'ARCHIVED_TASK', + ], + [ + 'archived triage', + 'task_triage', + { taskId: 'task-1', target: 'ACTIVE' }, + new TaskArchivedError('archived internal detail'), + 'ARCHIVED_TASK', + ], + [ + 'archived start', + 'task_start', + { taskId: 'task-1' }, + new TaskArchivedError('archived internal detail'), + 'ARCHIVED_TASK', + ], + [ + 'archived complete', + 'task_complete', + { taskId: 'task-1' }, + new TaskArchivedError('archived internal detail'), + 'ARCHIVED_TASK', + ], + [ + 'storage', + 'task_edit', + { taskId: 'task-1', title: 'Updated' }, + new TaskPersistenceError('SQLITE /tmp/relay.db'), + 'STORAGE_ERROR', + ], + [ + 'internal', + 'task_start', + { taskId: 'task-1' }, + new Error('secret implementation detail'), + 'INTERNAL_ERROR', + ], + ] as const)( + 'maps schema-valid %s execution errors without leaking details', + async (_name, name, arguments_, error, code) => { + const result = task(); + const method = + name === 'task_edit' + ? 'edit' + : name === 'task_triage' + ? 'activate' + : name === 'task_start' + ? 'start' + : name === 'task_complete' + ? 'complete' + : 'archive'; + const application = { + ...readMethods(result), + [method]: vi.fn(() => { + throw error; + }), + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response).toMatchObject({ + isError: true, + structuredContent: { error: { code } }, + }); + expect(JSON.stringify(response)).not.toContain(error.message); + expect(JSON.stringify(response)).not.toMatch(/SQLITE|relay\.db|stack/i); + } finally { + await close(); + } + }, + ); + + it.each([ + ['task_edit', { taskId: 'task-1', title: 'Updated' }, 'edit'], + ['task_start', { taskId: 'task-1' }, 'start'], + ['task_complete', { taskId: 'task-1' }, 'complete'], + ] as const)( + 'maps rejected %s mutation promises through the MCP error mapper', + async (name, arguments_, method) => { + const result = task(); + const application = { + ...readMethods(result), + [method]: vi.fn(() => Promise.reject(new TaskPersistenceError('secret storage detail'))), + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response).toMatchObject({ + isError: true, + structuredContent: { error: { code: 'STORAGE_ERROR' } }, + }); + expect(JSON.stringify(response)).not.toContain('secret storage detail'); + } finally { + await close(); + } + }, + ); + + it.each([ + ['task_start', { taskId: 'task-1', unknown: true }], + ['task_complete', { taskId: 'task-1', unknown: true }], + ['task_archive', { taskId: 'task-1', unknown: true }], + ] as const)('rejects unknown fields for %s as SDK invalid params', async (name, arguments_) => { + const result = task(); + const application = { + ...readMethods(result), + start: vi.fn(), + complete: vi.fn(), + archive: vi.fn(), + } as unknown as TaskApplication; + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response).toMatchObject({ isError: true }); + expect(JSON.stringify(response)).toContain('-32602'); + } finally { + await close(); + } + }); +});