diff --git a/README.md b/README.md index 8b2e038..2b588e9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Relay -The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). These are contract-only artifacts: production MCP and CLI task handlers remain downstream work. +The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). The production MCP task tools are shipped; CLI task handlers remain downstream work. -Relay is a local task sidecar for human–AI workflows. The current MVP is usable directly through its local web UI: it stores tasks on this computer and exposes a loopback-only HTTP API behind the UI. Production MCP task tools and companion skills are future work tracked separately under issue #2. +Relay is a local task sidecar for human–AI workflows. The current MVP is usable through its local web UI and through five safe local stdio MCP task tools. ## Prerequisites and setup @@ -37,6 +37,14 @@ pnpm build node dist/http/main.js ``` +To run the MCP server after building: + +```bash +node dist/mcp/main.js +``` + +It exposes five task tools—`task_capture`, `task_list`, `task_get`, `task_find_similar`, and `session_captures_list`—plus the separate `relay_health` tool. MCP task results use structured schema-versioned payloads; capture records AGENT provenance and reports possible duplicates as advisory warnings. + ## Database and safe development data Relay uses a local SQLite database containing task data. The default database file is: @@ -87,7 +95,7 @@ src/ database/ # SQLite connection, migrations, and task repository interfaces/ http/ # Loopback HTTP adapter and compiled UI serving - mcp/ # Separate scaffold/health adapter; no task behavior yet + mcp/ # MCP health and production task-tool adapter web/ # React UI that calls the HTTP API only ``` @@ -95,7 +103,7 @@ Adapters call application services. The React application calls the loopback HTT ## Current limitations -The MVP deliberately does not include production MCP task tools, due dates or reminders, labels or projects, search, recurring tasks, archive restoration, collaboration or cloud sync, packaging/installers, or mobile support. +The MVP includes production MCP task tools, but deliberately does not include due dates or reminders, labels or projects, search, recurring tasks, archive restoration, collaboration or cloud sync, packaging/installers, or mobile support. ## Troubleshooting diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 5948a75..ca7c281 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,14 +1,14 @@ # Relay MCP Tool Contracts -Issue #19 defines this version `1` contract only. It does not implement production MCP task handlers. Every tool returns structured `{ schemaVersion: 1, data, warnings }`; errors use `VALIDATION_ERROR`, `NOT_FOUND`, `CONFLICT`, `ARCHIVED_TASK`, `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 #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. ## `task_capture` -Input: required `title`, `createdByName`, and `sessionId`; optional `description`, `priority`, `workspace`, and `sourceContext`. The adapter—not the caller—sets `createdByType: AGENT` and `status: INBOX`; caller-supplied provenance or status is invalid. Output: `{ task, change: { action: "CREATED" } }`, with optional advisory `POSSIBLE_DUPLICATE` warnings. Capture always succeeds when a duplicate warning is returned. +Input: required `title`, `createdByName`, and `sessionId`; optional `description`, `priority`, `workspace`, and `sourceContext`. The adapter—not the caller—sets `createdByType: AGENT` and `status: INBOX`; caller-supplied provenance or status is invalid. Output: `{ task, change: { action: "CREATED" } }`, with optional advisory `POSSIBLE_DUPLICATE` warnings. Capture always succeeds when a duplicate warning is returned. ## `task_list` -Input: optional non-empty `statuses`, `workspace`, and `limit` from 1 through 100. Output: `{ tasks, count }`. This is a bounded read and has no lifecycle side effects. +Input: optional non-empty, non-duplicated `statuses`; optional `workspace`; and `limit` from 1 through 100. Output: `{ tasks, count }`. This is a bounded read and has no lifecycle side effects. ## `task_get` diff --git a/docs/superpowers/plans/2026-07-26-issue-26-mcp-tools.md b/docs/superpowers/plans/2026-07-26-issue-26-mcp-tools.md new file mode 100644 index 0000000..e57d9e1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-issue-26-mcp-tools.md @@ -0,0 +1,69 @@ +# Issue 26 MCP Tools 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:** Expose the approved task application through five safe, versioned MCP stdio tools. + +**Architecture:** `main.ts` composes one shared `TaskRuntime`, injects its `TaskApplication` into `createMcpServer`, and owns idempotent cleanup. Focused MCP modules validate #19 contracts, invoke application operations only, and map tasks/results/errors to structured MCP responses; they never access SQLite. + +**Tech Stack:** TypeScript, Zod 4, MCP SDK 1.29, Vitest, SQLite runtime. + +## Global Constraints + +- Preserve `relay_health` and protocol-clean stdout. +- Success payloads are `{ schemaVersion: 1, data, warnings }`; structured content is authoritative. +- Strictly reject unknown input keys and caller-controlled task status/provenance. +- Error codes are `VALIDATION_ERROR`, `NOT_FOUND`, `STORAGE_ERROR`, and `INTERNAL_ERROR`; never leak causes, SQL, paths, or stacks. +- `task_capture` always creates after advisory duplicate lookup and forces `creator.type: 'AGENT'`. +- No direct persistence access in MCP modules and no unrelated mutation tools. + +--- + +### Task 1: Complete the approved list-query contract + +**Files:** Modify `src/application/tasks/{task-application.ts,use-cases/list-tasks.ts,task-repository.ts}`, `src/database/tasks/sqlite-task-repository.ts`; test `tests/{unit/application/tasks/task-application.test.ts,integration/task-repository.test.ts}`. + +- [ ] Write failing tests that pass `workspace` with a list request and prove an exact workspace filter is applied in the repository before `limit`. +- [ ] Run the focused tests and confirm they fail because the application query lacks `workspace`. +- [ ] Extend `ListTasksInput`/`TaskListQuery` with optional normalized workspace; validate it in the application and add SQL `workspace = ?` before ordering/limit. +- [ ] Re-run focused unit and repository integration tests; commit the narrowly scoped #19 contract correction. + +### Task 2: Establish MCP schemas and stable mappers + +**Files:** Create `src/interfaces/mcp/{schemas/read-tool-schemas.ts,mapping/task-mcp-dto.ts,mapping/mcp-result.ts,mapping/mcp-errors.ts}`; modify MCP unit tests. + +- [ ] Write failing in-memory server tests for strict schemas, `schemaVersion: 1`, structured data, and mapped validation/not-found/storage/internal errors without implementation text. +- [ ] Run the test and confirm failures reflect absent task-tool registration/mapping. +- [ ] Re-export/compose only #19 schemas, map domain tasks to contract DTOs, emit compact JSON text plus `structuredContent`, and map known error classes to safe error envelopes. +- [ ] Re-run MCP unit tests and commit. + +### Task 3: Register the four read-only handlers + +**Files:** Create `src/interfaces/mcp/tools/{register-read-tools.ts,task-list.ts,task-get.ts,task-find-similar.ts,session-captures-list.ts}`; modify `create-mcp-server.ts`; test `tests/unit/interfaces/mcp/create-mcp-server.test.ts`. + +- [ ] Add failing in-memory tests covering discovery plus list/get/find/session success, invalid session/unknown keys, not-found, persisted order, isolation, and result bounds. +- [ ] Run the focused test and verify the new tools are unavailable. +- [ ] Register handlers that parse input, call the injected `TaskApplication`, and convert results only through Task 2 mapping helpers. +- [ ] Re-run focused tests, then commit. + +### Task 4: Add autonomous capture last + +**Files:** Create `src/interfaces/mcp/tools/task-capture.ts`; modify `register-read-tools.ts` or a focused registration module; test MCP unit tests. + +- [ ] Add failing tests showing capture calls `findSimilar` before `create`, rejects `status`/creator type, forces AGENT provenance, preserves session metadata, and returns advisory duplicate candidates without blocking creation. +- [ ] Run the focused test and verify the capture tool is unavailable. +- [ ] Implement strict capture parsing, duplicate warning construction, forced creator mapping, and `CREATED` result mapping without persistence/lifecycle logic. +- [ ] Re-run focused tests and commit. + +### Task 5: Compose lifecycle, built-process proof, and documentation + +**Files:** Modify `src/interfaces/mcp/main.ts`, `tests/integration/mcp-stdio.test.ts`, `README.md`, `docs/mcp-tools.md`, and asset tests only if paths change. + +- [ ] Add failing tests for built-process capture followed by session retrieval using an isolated `RELAY_DB_PATH`, clean stdout, runtime cleanup exactly once on signal, and cleanup after startup/connect failure where dependency seams permit. +- [ ] Run the focused integration tests and confirm the missing runtime injection/lifecycle behavior. +- [ ] Create runtime in `main.ts`, inject it, close server/runtime exactly once on signal and construction/connect failures, and send diagnostics only to stderr; document all five tools and contract guarantees. +- [ ] Run `pnpm test -- tests/unit/interfaces/mcp`, `pnpm test -- tests/integration/mcp-stdio.test.ts`, then `pnpm verify`; commit the final implementation. + +## Spec coverage review + +Tasks 2-4 cover all five tool schemas, success/error envelopes, provenance, duplicate warnings, session isolation/order, and strict input handling. Task 1 is the documented #19/#20 workspace-filter correction. Task 5 covers stdio lifecycle, disposable database integration, documentation, assets, and the full quality gate. No out-of-scope mutation, persistence redesign, packaging, auth, or daemon work is included. diff --git a/src/application/tasks/task-repository.ts b/src/application/tasks/task-repository.ts index d3ad24d..5d911a5 100644 --- a/src/application/tasks/task-repository.ts +++ b/src/application/tasks/task-repository.ts @@ -3,6 +3,7 @@ import type { TaskStatus } from '../../domain/task/task-status.js'; export interface TaskListQuery { readonly statuses: readonly TaskStatus[]; + readonly workspace?: string | null; readonly limit: number; } export interface SessionCaptureQuery { diff --git a/src/application/tasks/use-cases/list-tasks.ts b/src/application/tasks/use-cases/list-tasks.ts index 76b9be1..2c6cf30 100644 --- a/src/application/tasks/use-cases/list-tasks.ts +++ b/src/application/tasks/use-cases/list-tasks.ts @@ -6,6 +6,7 @@ import { persist } from './repository-operations.js'; export interface ListTasksInput { readonly statuses: readonly TaskStatus[]; + readonly workspace?: string | null; readonly limit?: number; } export function listTasksUseCase( @@ -18,5 +19,15 @@ export function listTasksUseCase( if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new InvalidTaskRequestError('Task list limit must be an integer from 1 through 200.'); const statuses = [...new Set(input.statuses)]; - return persist(() => repository.list({ statuses, limit }), 'Tasks could not be listed.'); + if ( + input.workspace !== undefined && + input.workspace !== null && + typeof input.workspace !== 'string' + ) + throw new InvalidTaskRequestError('workspace must be a string or null.'); + const workspace = input.workspace === undefined ? undefined : input.workspace?.trim() || null; + return persist( + () => repository.list({ statuses, ...(workspace === undefined ? {} : { workspace }), limit }), + 'Tasks could not be listed.', + ); } diff --git a/src/database/tasks/sqlite-task-repository.ts b/src/database/tasks/sqlite-task-repository.ts index c0eb13c..d755215 100644 --- a/src/database/tasks/sqlite-task-repository.ts +++ b/src/database/tasks/sqlite-task-repository.ts @@ -27,7 +27,7 @@ export class SqliteTaskRepository implements TaskRepository { private readonly insertStatement: Database.Statement; private readonly findStatement: Database.Statement<[string], TaskRow>; private readonly updateStatement: Database.Statement; - private readonly listStatements = new Map>(); + private readonly listStatements = new Map>(); private readonly sessionCaptureStatement: Database.Statement<[string, number], TaskRow>; private readonly similarStatements = new Map>(); @@ -153,8 +153,11 @@ export class SqliteTaskRepository implements TaskRepository { validateListQuery(query); try { - const statement = this.getListStatement(query.statuses.length); - const rows = statement.all(...query.statuses, query.limit); + const statement = this.getListStatement(query.statuses.length, query.workspace !== undefined); + const rows = + query.workspace === undefined + ? statement.all(...query.statuses, query.limit) + : statement.all(...query.statuses, query.workspace, query.limit); return rows.map(taskRowToDomain); } catch (error) { if (error instanceof TaskRepositoryError) { @@ -197,8 +200,12 @@ export class SqliteTaskRepository implements TaskRepository { } } - private getListStatement(statusCount: number): Database.Statement { - const existing = this.listStatements.get(statusCount); + private getListStatement( + statusCount: number, + filteredByWorkspace: boolean, + ): Database.Statement { + const key = `${statusCount}:${filteredByWorkspace}`; + const existing = this.listStatements.get(key); if (existing !== undefined) { return existing; } @@ -208,10 +215,11 @@ export class SqliteTaskRepository implements TaskRepository { SELECT ${TASK_COLUMN_LIST} FROM tasks WHERE status IN (${placeholders}) + ${filteredByWorkspace ? 'AND workspace IS ?' : ''} ORDER BY updated_at DESC, created_at DESC, id ASC LIMIT ? `); - this.listStatements.set(statusCount, statement); + this.listStatements.set(key, statement); return statement; } @@ -273,6 +281,13 @@ function validateListQuery(query: TaskListQuery): void { if (new Set(query.statuses).size !== query.statuses.length) { throw new TaskRepositoryError('Task status filters must not contain duplicates.'); } + if ( + query.workspace !== undefined && + query.workspace !== null && + typeof query.workspace !== 'string' + ) { + throw new TaskRepositoryError('Task list workspace must be a string or null.'); + } if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 200) { throw new TaskRepositoryError('Task list limit must be an integer from 1 through 200.'); } diff --git a/src/interfaces/contracts/task-contract.ts b/src/interfaces/contracts/task-contract.ts index c5a4f73..d48f76e 100644 --- a/src/interfaces/contracts/task-contract.ts +++ b/src/interfaces/contracts/task-contract.ts @@ -43,7 +43,13 @@ export const agentCaptureInputSchema = z export const taskListInputSchema = z .object({ - statuses: z.array(taskStatusSchema).min(1).optional(), + statuses: z + .array(taskStatusSchema) + .min(1) + .refine((statuses) => new Set(statuses).size === statuses.length, { + message: 'statuses must not contain duplicates', + }) + .optional(), workspace: optionalText(255), limit: z.number().int().min(1).max(100).default(100), }) diff --git a/src/interfaces/mcp/create-mcp-server.ts b/src/interfaces/mcp/create-mcp-server.ts index d0244b1..be7c6b4 100644 --- a/src/interfaces/mcp/create-mcp-server.ts +++ b/src/interfaces/mcp/create-mcp-server.ts @@ -1,13 +1,18 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getHealth } from '../../application/health/get-health.js'; 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'; -export function createMcpServer(): McpServer { +export function createMcpServer(taskApplication: TaskApplication): McpServer { const meta = getPackageMetadata(); const server = new McpServer({ name: meta.name, version: meta.version, }); + registerReadTools(server, taskApplication); + registerTaskCaptureTool(server, taskApplication); server.tool('relay_health', 'Return health status of the local Relay service', {}, async () => { const health = getHealth(); diff --git a/src/interfaces/mcp/main.ts b/src/interfaces/mcp/main.ts index c94d96b..68985ec 100644 --- a/src/interfaces/mcp/main.ts +++ b/src/interfaces/mcp/main.ts @@ -1,43 +1,20 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createMcpServer } from './create-mcp-server.js'; import { mcpLogger } from './logger.js'; +import { createTaskRuntime } from '../shared/create-task-runtime.js'; +import { runMcpServer } from './run-mcp-server.js'; async function main(): Promise { - try { - const server = createMcpServer(); - const transport = new StdioServerTransport(); - let shuttingDown = false; - - const shutdown = async (signal: 'SIGINT' | 'SIGTERM'): Promise => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - mcpLogger.info(`Received ${signal}, shutting down MCP server...`); - - try { - await server.close(); - process.exitCode = 0; - } catch (error) { - mcpLogger.error('Failed during MCP shutdown', error); - process.exitCode = 1; - } - }; - - process.on('SIGINT', () => { - void shutdown('SIGINT'); - }); - - process.on('SIGTERM', () => { - void shutdown('SIGTERM'); - }); - - await server.connect(transport); - } catch (error) { - mcpLogger.error('Fatal error starting MCP stdio server', error); - process.exit(1); - } + await runMcpServer({ + createRuntime: createTaskRuntime, + createServer: createMcpServer, + createTransport: () => new StdioServerTransport(), + onSignal: (signal, handler) => process.on(signal, handler), + reportFatal: (error) => { + mcpLogger.error('Fatal error starting MCP stdio server', error); + process.exitCode = 1; + }, + }); } void main(); diff --git a/src/interfaces/mcp/mapping/mcp-errors.ts b/src/interfaces/mcp/mapping/mcp-errors.ts new file mode 100644 index 0000000..bf56749 --- /dev/null +++ b/src/interfaces/mcp/mapping/mcp-errors.ts @@ -0,0 +1,21 @@ +import { ZodError } from 'zod'; +import { + InvalidTaskRequestError, + TaskNotFoundError, + TaskPersistenceError, +} from '../../../application/tasks/task-application-errors.js'; +import { TaskDomainError } from '../../../domain/task/task-errors.js'; +import { mcpError } from './mcp-result.js'; + +export function toMcpError(error: unknown) { + if ( + error instanceof ZodError || + error instanceof InvalidTaskRequestError || + error instanceof TaskDomainError + ) + return mcpError('VALIDATION_ERROR', 'Request validation failed.'); + if (error instanceof TaskNotFoundError) return mcpError('NOT_FOUND', 'Task was not found.'); + if (error instanceof TaskPersistenceError) + return mcpError('STORAGE_ERROR', 'Task storage operation failed.'); + return mcpError('INTERNAL_ERROR', 'An unexpected internal error occurred.'); +} diff --git a/src/interfaces/mcp/mapping/mcp-result.ts b/src/interfaces/mcp/mapping/mcp-result.ts new file mode 100644 index 0000000..b2e85e9 --- /dev/null +++ b/src/interfaces/mcp/mapping/mcp-result.ts @@ -0,0 +1,18 @@ +import { CONTRACT_SCHEMA_VERSION } from '../../contracts/contract-version.js'; + +export function mcpSuccess(data: Record, warnings: readonly unknown[] = []) { + const structuredContent = { schemaVersion: CONTRACT_SCHEMA_VERSION, data, warnings }; + return { + structuredContent, + content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }], + }; +} + +export function mcpError(code: string, message: string) { + const structuredContent = { schemaVersion: CONTRACT_SCHEMA_VERSION, error: { code, message } }; + return { + isError: true, + structuredContent, + content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }], + }; +} diff --git a/src/interfaces/mcp/mapping/task-mcp-dto.ts b/src/interfaces/mcp/mapping/task-mcp-dto.ts new file mode 100644 index 0000000..2883f8c --- /dev/null +++ b/src/interfaces/mcp/mapping/task-mcp-dto.ts @@ -0,0 +1,9 @@ +import type { Task } from '../../../domain/task/task.js'; +import type { TaskDto } from '../../http/task-dto.js'; + +export function toTaskMcpDto(task: Task): TaskDto { + return { ...task }; +} +export function matchReason(task: Task, title: string): 'EXACT_TITLE' | 'NORMALIZED_TITLE' { + return task.title === title.trim() ? 'EXACT_TITLE' : 'NORMALIZED_TITLE'; +} diff --git a/src/interfaces/mcp/run-mcp-server.ts b/src/interfaces/mcp/run-mcp-server.ts new file mode 100644 index 0000000..5399fee --- /dev/null +++ b/src/interfaces/mcp/run-mcp-server.ts @@ -0,0 +1,50 @@ +import type { TaskApplication } from '../../application/tasks/task-application.js'; +import type { TaskRuntime } from '../shared/create-task-runtime.js'; + +interface McpServerLike { + connect(transport: unknown): Promise; + close(): Promise; +} +export interface McpServerDependencies { + createRuntime: () => TaskRuntime; + createServer: (taskApplication: TaskApplication) => McpServerLike; + createTransport: () => unknown; + onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void; + reportFatal: (error: unknown) => void; +} + +export async function runMcpServer(dependencies: McpServerDependencies): Promise { + let runtime: TaskRuntime | undefined; + let server: McpServerLike | undefined; + let shutdown: Promise | undefined; + const close = (): Promise => { + shutdown ??= (async () => { + try { + await server?.close(); + } finally { + runtime?.close(); + } + })(); + return shutdown; + }; + try { + runtime = dependencies.createRuntime(); + server = dependencies.createServer(runtime.taskApplication); + dependencies.onSignal('SIGINT', () => { + void close().catch(dependencies.reportFatal); + }); + dependencies.onSignal('SIGTERM', () => { + void close().catch(dependencies.reportFatal); + }); + await server.connect(dependencies.createTransport()); + return true; + } catch (error) { + try { + await close(); + } catch (shutdownError) { + dependencies.reportFatal(shutdownError); + } + dependencies.reportFatal(error); + return false; + } +} diff --git a/src/interfaces/mcp/schemas/read-tool-schemas.ts b/src/interfaces/mcp/schemas/read-tool-schemas.ts new file mode 100644 index 0000000..169c2ea --- /dev/null +++ b/src/interfaces/mcp/schemas/read-tool-schemas.ts @@ -0,0 +1,40 @@ +import { + agentCaptureInputSchema, + findSimilarInputSchema, + taskGetInputSchema, + taskListInputSchema, +} from '../../contracts/task-contract.js'; +import { sessionCapturesInputSchema } from '../../contracts/session-contract.js'; +import { + sessionCapturesResultSchema, + taskCaptureResultSchema, + taskFindSimilarResultSchema, + 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'; + +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 { + agentCaptureInputSchema, + findSimilarInputSchema, + taskGetInputSchema, + taskListInputSchema, + sessionCapturesInputSchema, +}; diff --git a/src/interfaces/mcp/tools/register-read-tools.ts b/src/interfaces/mcp/tools/register-read-tools.ts new file mode 100644 index 0000000..5a44d5c --- /dev/null +++ b/src/interfaces/mcp/tools/register-read-tools.ts @@ -0,0 +1,103 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { matchReason, toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { + findSimilarInputSchema, + sessionCapturesOutputSchema, + sessionCapturesInputSchema, + taskFindSimilarOutputSchema, + taskGetOutputSchema, + taskGetInputSchema, + taskListOutputSchema, + taskListInputSchema, +} from '../schemas/read-tool-schemas.js'; +import { TASK_STATUSES } from '../../../domain/task/task-status.js'; + +export function registerReadTools(server: McpServer, taskApplication: TaskApplication): void { + server.registerTool( + 'task_list', + { + description: 'List approved Relay tasks', + inputSchema: taskListInputSchema, + outputSchema: taskListOutputSchema, + }, + async (input) => { + try { + const parsed = input; + const tasks = taskApplication.list({ + statuses: parsed.statuses ?? TASK_STATUSES, + limit: parsed.limit, + ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), + }); + return mcpSuccess({ tasks: tasks.map(toTaskMcpDto), count: tasks.length }); + } catch (error) { + return toMcpError(error); + } + }, + ); + server.registerTool( + 'task_get', + { + description: 'Get one Relay task', + inputSchema: taskGetInputSchema, + outputSchema: taskGetOutputSchema, + }, + async (input) => { + try { + const parsed = input; + return mcpSuccess({ task: toTaskMcpDto(taskApplication.get({ id: parsed.taskId })) }); + } catch (error) { + return toMcpError(error); + } + }, + ); + server.registerTool( + 'task_find_similar', + { + description: 'Find advisory similar Relay tasks', + inputSchema: findSimilarInputSchema, + outputSchema: taskFindSimilarOutputSchema, + }, + async (input) => { + try { + const parsed = input; + const candidates = taskApplication + .findSimilar({ + title: parsed.title, + limit: parsed.limit, + ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), + }) + .map((task) => ({ + task: toTaskMcpDto(task), + matchReason: matchReason(task, parsed.title), + })); + return mcpSuccess({ candidates }); + } catch (error) { + return toMcpError(error); + } + }, + ); + server.registerTool( + 'session_captures_list', + { + description: 'List captured Relay tasks for a session', + inputSchema: sessionCapturesInputSchema, + outputSchema: sessionCapturesOutputSchema, + }, + async (input) => { + try { + const parsed = input; + const tasks = taskApplication.listSessionCaptures(parsed); + return mcpSuccess({ + sessionId: parsed.sessionId, + tasks: tasks.map(toTaskMcpDto), + count: tasks.length, + }); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/src/interfaces/mcp/tools/task-capture.ts b/src/interfaces/mcp/tools/task-capture.ts new file mode 100644 index 0000000..ffe09f1 --- /dev/null +++ b/src/interfaces/mcp/tools/task-capture.ts @@ -0,0 +1,49 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toMcpError } from '../mapping/mcp-errors.js'; +import { mcpSuccess } from '../mapping/mcp-result.js'; +import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { agentCaptureInputSchema, taskCaptureOutputSchema } from '../schemas/read-tool-schemas.js'; + +export function registerTaskCaptureTool(server: McpServer, taskApplication: TaskApplication): void { + server.registerTool( + 'task_capture', + { + description: 'Capture an autonomous Relay task', + inputSchema: agentCaptureInputSchema, + outputSchema: taskCaptureOutputSchema, + }, + async (input) => { + try { + const parsed = input; + const matches = taskApplication.findSimilar({ + title: parsed.title, + ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), + limit: 5, + }); + const task = taskApplication.create({ + title: parsed.title, + ...(parsed.description === undefined ? {} : { description: parsed.description }), + ...(parsed.priority === undefined ? {} : { priority: parsed.priority }), + ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), + ...(parsed.sourceContext === undefined ? {} : { sourceContext: parsed.sourceContext }), + sessionId: parsed.sessionId, + creator: { type: 'AGENT', name: parsed.createdByName }, + }); + const warnings = + matches.length === 0 + ? [] + : [ + { + code: 'POSSIBLE_DUPLICATE', + message: 'Similar tasks already exist.', + candidates: matches.map((candidate) => ({ id: candidate.id })), + }, + ]; + return mcpSuccess({ task: toTaskMcpDto(task), change: { action: 'CREATED' } }, warnings); + } catch (error) { + return toMcpError(error); + } + }, + ); +} diff --git a/tests/integration/mcp-stdio.test.ts b/tests/integration/mcp-stdio.test.ts index 3d2b204..958c54a 100644 --- a/tests/integration/mcp-stdio.test.ts +++ b/tests/integration/mcp-stdio.test.ts @@ -1,46 +1,140 @@ -import { mkdtempSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { execSync } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; describe('mcp-stdio integration', () => { beforeAll(() => { execSync('pnpm build:node', { stdio: 'inherit' }); - }); + }, 120_000); - it('spawns built MCP stdio process and calls relay_health tool cleanly', async () => { + it('preserves discovery, sessions, duplicates, and protocol operation in the built stdio process', async () => { const builtJsPath = join(process.cwd(), 'dist', 'mcp', 'main.js'); const launchDir = mkdtempSync(join(tmpdir(), 'relay-mcp-launch-')); + const databasePath = join(launchDir, 'relay.db'); const transport = new StdioClientTransport({ command: 'node', args: [builtJsPath], cwd: launchDir, + env: { ...process.env, RELAY_DB_PATH: databasePath }, }); const client = new Client({ name: 'integration-tester', version: '1.0.0' }); - await client.connect(transport); + try { + await client.connect(transport); - const tools = await client.listTools(); - expect(tools.tools.some((t) => t.name === 'relay_health')).toBe(true); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + 'relay_health', + 'task_capture', + 'task_list', + 'task_get', + 'task_find_similar', + 'session_captures_list', + ]), + ); - const res = (await client.callTool({ name: 'relay_health', arguments: {} })) as { - content: Array<{ type: string; text: string }>; - }; - expect(res.content[0]?.type).toBe('text'); - if (res.content[0]?.type === 'text') { - const payload = JSON.parse(res.content[0].text) as { - name: string; - status: string; - version: string; + const res = (await client.callTool({ name: 'relay_health', arguments: {} })) as { + content: Array<{ type: string; text: string }>; }; - expect(payload).toEqual({ name: 'relay', status: 'ok', version: '0.1.0' }); + expect(res.content[0]?.type).toBe('text'); + if (res.content[0]?.type === 'text') { + expect(JSON.parse(res.content[0].text)).toEqual({ + name: 'relay', + status: 'ok', + version: '0.1.0', + }); + } + + const capture = async (title: string, sessionId: string) => + (await client.callTool({ + name: 'task_capture', + arguments: { title, createdByName: 'Integration tester', sessionId }, + })) as { + structuredContent?: { + data?: { task?: { id?: string; sessionId?: string; createdByType?: string } }; + warnings?: Array<{ code: string }>; + }; + }; + const first = await capture('Persist MCP capture', 'stdio-session-a'); + const second = await capture('Persist MCP capture', 'stdio-session-a'); + const otherSession = await capture('Session B capture', 'stdio-session-b'); + expect(first.structuredContent?.data?.task).toMatchObject({ + sessionId: 'stdio-session-a', + createdByType: 'AGENT', + }); + expect(second.structuredContent?.warnings).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'POSSIBLE_DUPLICATE' })]), + ); + expect(otherSession.structuredContent?.data?.task?.sessionId).toBe('stdio-session-b'); + + const sessionA = (await client.callTool({ + name: 'session_captures_list', + arguments: { sessionId: 'stdio-session-a' }, + })) as { structuredContent?: { data?: { tasks?: Array<{ id: string }>; count?: number } } }; + const sessionB = (await client.callTool({ + name: 'session_captures_list', + arguments: { sessionId: 'stdio-session-b' }, + })) as { structuredContent?: { data?: { tasks?: Array<{ id: string }>; count?: number } } }; + expect(sessionA.structuredContent?.data?.count).toBe(2); + expect(sessionA.structuredContent?.data?.tasks?.map((task) => task.id)).toEqual( + expect.arrayContaining([ + first.structuredContent?.data?.task?.id, + second.structuredContent?.data?.task?.id, + ]), + ); + expect(sessionB.structuredContent?.data?.count).toBe(1); + + const invalid = await client.callTool({ + name: 'task_capture', + arguments: { + title: 'Invalid', + createdByName: 'Integration tester', + sessionId: 'bad session', + }, + }); + expect(invalid).toMatchObject({ isError: true }); + const healthyAfterInvalid = await client.callTool({ name: 'relay_health', arguments: {} }); + expect(healthyAfterInvalid).not.toHaveProperty('isError'); + } finally { + await transport.close(); + rmSync(launchDir, { recursive: true, force: true }); } + }); + + it('reports built-process startup failures only on stderr and exits non-zero', async () => { + const builtJsPath = join(process.cwd(), 'dist', 'mcp', 'main.js'); + const launchDir = mkdtempSync(join(tmpdir(), 'relay-mcp-failure-')); + const databaseDirectory = join(launchDir, 'database-directory'); + mkdirSync(databaseDirectory); + try { + const child = spawn('node', [builtJsPath], { + cwd: launchDir, + env: { ...process.env, RELAY_DB_PATH: databaseDirectory }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const [exitCode] = await once(child, 'close'); - await transport.close(); + expect(exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('[ERROR] Fatal error starting MCP stdio server'); + } finally { + rmSync(launchDir, { recursive: true, force: true }); + } }); }); diff --git a/tests/integration/task-repository.test.ts b/tests/integration/task-repository.test.ts index 46b038c..08ed9d1 100644 --- a/tests/integration/task-repository.test.ts +++ b/tests/integration/task-repository.test.ts @@ -382,6 +382,63 @@ describe('SQLite task repository list', () => { expect(repository.list({ statuses: ['INBOX'], limit: 200 })).toEqual([newer, older]); }); + it('filters workspace in SQL before applying the result limit', () => { + const repository = createRepository(); + const relayOlder = taskFixture({ + id: 'relay-older', + workspace: 'relay', + updatedAt: '2026-07-25T10:00:00.000Z', + }); + const relayNewer = taskFixture({ + id: 'relay-newer', + workspace: 'relay', + updatedAt: '2026-07-25T11:00:00.000Z', + }); + repository.create( + taskFixture({ + id: 'other-newest', + workspace: 'other', + updatedAt: '2026-07-25T13:00:00.000Z', + }), + ); + repository.create( + taskFixture({ id: 'other-newer', workspace: 'other', updatedAt: '2026-07-25T12:00:00.000Z' }), + ); + repository.create(relayOlder); + repository.create(relayNewer); + + expect(repository.list({ statuses: ['INBOX'], workspace: 'relay', limit: 2 })).toEqual([ + relayNewer, + relayOlder, + ]); + expect(repository.list({ statuses: ['INBOX'], workspace: null, limit: 2 })).toEqual([]); + }); + + it('keeps filtered and unfiltered list statements separate for the same status shape', () => { + const repository = createRepository(); + const relay = taskFixture({ id: 'relay', workspace: 'relay' }); + const other = taskFixture({ + id: 'other', + workspace: 'other', + updatedAt: '2026-07-25T10:00:00.000Z', + }); + const unassigned = taskFixture({ + id: 'unassigned', + workspace: null, + updatedAt: '2026-07-25T11:00:00.000Z', + }); + for (const task of [relay, other, unassigned]) repository.create(task); + + expect(repository.list({ statuses: ['INBOX'], workspace: 'relay', limit: 10 })).toEqual([ + relay, + ]); + expect(repository.list({ statuses: ['INBOX'], limit: 10 })).toEqual([unassigned, other, relay]); + expect(repository.list({ statuses: ['INBOX'], workspace: null, limit: 10 })).toEqual([ + unassigned, + ]); + expect(repository.list({ statuses: ['INBOX'], limit: 10 })).toEqual([unassigned, other, relay]); + }); + it.each([ [{ statuses: [], limit: 1 }, 'empty statuses'], [{ statuses: ['INBOX'] as const, limit: 0 }, 'zero limit'], diff --git a/tests/unit/application/tasks/task-application.test.ts b/tests/unit/application/tasks/task-application.test.ts index ef14681..7e347de 100644 --- a/tests/unit/application/tasks/task-application.test.ts +++ b/tests/unit/application/tasks/task-application.test.ts @@ -166,6 +166,35 @@ describe('TaskApplication', () => { ); }); + it('preserves omitted workspace and normalizes explicit workspace filters before querying', () => { + const { application, repository } = setup(); + + application.list({ statuses: ['INBOX'] }); + expect(repository.lastListQuery).toEqual({ statuses: ['INBOX'], limit: 100 }); + + application.list({ statuses: ['INBOX'], workspace: ' relay ' }); + expect(repository.lastListQuery).toEqual({ + statuses: ['INBOX'], + workspace: 'relay', + limit: 100, + }); + + application.list({ statuses: ['INBOX'], workspace: null }); + expect(repository.lastListQuery).toEqual({ statuses: ['INBOX'], workspace: null, limit: 100 }); + + application.list({ statuses: ['INBOX'], workspace: ' ' }); + expect(repository.lastListQuery).toEqual({ statuses: ['INBOX'], workspace: null, limit: 100 }); + }); + + it.each([123, true, {}, []])('rejects non-string workspace values: %s', (workspace) => { + const { application, repository } = setup(); + + expect(() => application.list({ statuses: ['INBOX'], workspace: workspace as never })).toThrow( + InvalidTaskRequestError, + ); + expect(repository.listCalls).toBe(0); + }); + it('validates session capture requests and applies the default limit', () => { const { application, repository } = setup(); const capture = task({ diff --git a/tests/unit/application/tasks/task-test-fixtures.ts b/tests/unit/application/tasks/task-test-fixtures.ts index 5137409..8fa36eb 100644 --- a/tests/unit/application/tasks/task-test-fixtures.ts +++ b/tests/unit/application/tasks/task-test-fixtures.ts @@ -68,6 +68,7 @@ export class InMemoryTaskRepository implements TaskRepository { if (this.listFailure !== null) throw this.listFailure; return [...this.tasks.values()] .filter((task) => query.statuses.includes(task.status)) + .filter((task) => query.workspace === undefined || task.workspace === query.workspace) .slice(0, query.limit); } diff --git a/tests/unit/interfaces/mcp/create-mcp-server.test.ts b/tests/unit/interfaces/mcp/create-mcp-server.test.ts index 954a546..d59297b 100644 --- a/tests/unit/interfaces/mcp/create-mcp-server.test.ts +++ b/tests/unit/interfaces/mcp/create-mcp-server.test.ts @@ -2,30 +2,167 @@ 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; + } +} describe('createMcpServer', () => { it('exposes relay_health tool via in-memory transport', async () => { - const server = createMcpServer(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer({ + ...createTaskApplication({ repository: new InMemoryTaskRepository() }), + }); + const { client, close } = await connectMcp(server); + try { + const tools = await client.listTools(); + expect(tools.tools.some((t) => t.name === 'relay_health')).toBe(true); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + const result = (await client.callTool({ name: 'relay_health', arguments: {} })) as { + content: Array<{ type: string; text: string }>; + }; + expect(result.content[0]?.type).toBe('text'); + if (result.content[0]?.type === 'text') { + const parsed = JSON.parse(result.content[0].text) as { + name: string; + status: string; + version: string; + }; + expect(parsed).toEqual({ name: 'relay', status: 'ok', version: '0.1.0' }); + } + } finally { + await close(); + } + }); - await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + it('exposes the five approved task tools', async () => { + const server = createMcpServer( + createTaskApplication({ repository: new InMemoryTaskRepository() }), + ); + const { client, close } = await connectMcp(server); + try { + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + 'relay_health', + 'task_capture', + 'task_list', + 'task_get', + 'task_find_similar', + 'session_captures_list', + ]), + ); + } finally { + await close(); + } + }); - const tools = await client.listTools(); - expect(tools.tools.some((t) => t.name === 'relay_health')).toBe(true); - - const result = (await client.callTool({ name: 'relay_health', arguments: {} })) as { - content: Array<{ type: string; text: string }>; - }; - expect(result.content[0]?.type).toBe('text'); - if (result.content[0]?.type === 'text') { - const parsed = JSON.parse(result.content[0].text) as { - name: string; - status: string; - version: string; + it('advertises the strict capture and bounded read-tool contracts', async () => { + const server = createMcpServer( + createTaskApplication({ repository: new InMemoryTaskRepository() }), + ); + const { client, close } = await connectMcp(server); + try { + const tools = await client.listTools(); + const capture = tools.tools.find((tool) => tool.name === 'task_capture'); + const list = tools.tools.find((tool) => tool.name === 'task_list'); + const similar = tools.tools.find((tool) => tool.name === 'task_find_similar'); + + expect(capture?.inputSchema).toMatchObject({ + additionalProperties: false, + required: expect.arrayContaining(['title', 'createdByName', 'sessionId']), + }); + expect(capture?.inputSchema.properties).not.toHaveProperty('status'); + expect(capture?.inputSchema.properties).not.toHaveProperty('creator'); + expect(list?.inputSchema.properties?.limit).toMatchObject({ minimum: 1, maximum: 100 }); + expect(similar?.inputSchema.properties?.limit).toMatchObject({ minimum: 1, maximum: 5 }); + } finally { + await close(); + } + }); + + it('rejects an unsafe capture field as an MCP invalid-parameter error', async () => { + const server = createMcpServer( + createTaskApplication({ repository: new InMemoryTaskRepository() }), + ); + const { client, close } = await connectMcp(server); + try { + const result = (await client.callTool({ + name: 'task_capture', + arguments: { + title: 'Capture safely', + createdByName: 'Codex', + sessionId: 'session-26', + status: 'DONE', + }, + })) as { isError?: boolean; content: Array<{ type: string; text?: string }> }; + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('MCP error -32602'); + } finally { + await close(); + } + }); + + it('captures a task and exposes it through each approved read tool', 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: 'Prepare issue twenty six', + createdByName: 'Codex', + sessionId: 'session-26', + workspace: 'relay', + }, + })) as unknown as { + structuredContent: { schemaVersion: number; data: { task: { id: string } } }; }; - expect(parsed).toEqual({ name: 'relay', status: 'ok', version: '0.1.0' }); + const taskId = capture.structuredContent.data.task.id; + + expect(capture.structuredContent.schemaVersion).toBe(1); + expect( + (await client.callTool({ name: 'task_get', arguments: { taskId } })) as unknown as { + structuredContent: { data: { task: { id: string } } }; + }, + ).toMatchObject({ structuredContent: { data: { task: { id: taskId } } } }); + expect( + (await client.callTool({ + name: 'task_list', + arguments: { statuses: ['INBOX'], workspace: 'relay' }, + })) as unknown as { structuredContent: { data: { count: number } } }, + ).toMatchObject({ structuredContent: { data: { count: 1 } } }); + expect( + (await client.callTool({ + name: 'task_find_similar', + arguments: { title: 'Prepare issue twenty six' }, + })) as unknown as { structuredContent: { data: { candidates: unknown[] } } }, + ).toMatchObject({ structuredContent: { data: { candidates: [expect.anything()] } } }); + expect( + (await client.callTool({ + name: 'session_captures_list', + arguments: { sessionId: 'session-26' }, + })) as unknown as { structuredContent: { data: { sessionId: string; count: number } } }, + ).toMatchObject({ structuredContent: { data: { sessionId: 'session-26', count: 1 } } }); + } finally { + await close(); } }); }); diff --git a/tests/unit/interfaces/mcp/logger.test.ts b/tests/unit/interfaces/mcp/logger.test.ts new file mode 100644 index 0000000..c87d250 --- /dev/null +++ b/tests/unit/interfaces/mcp/logger.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mcpLogger } from '../../../../src/interfaces/mcp/logger.js'; + +describe('mcpLogger', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + afterEach(() => { + stderr.mockClear(); + stdout.mockClear(); + stderr.mockRestore(); + stdout.mockRestore(); + }); + + it('writes startup diagnostics to stderr without contaminating stdout', () => { + mcpLogger.error('Fatal error starting MCP stdio server', new Error('connection refused')); + + expect(stderr).toHaveBeenCalledWith( + '[ERROR] Fatal error starting MCP stdio server: connection refused\n', + ); + expect(stdout).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/interfaces/mcp/mcp-tool-contracts.test.ts b/tests/unit/interfaces/mcp/mcp-tool-contracts.test.ts new file mode 100644 index 0000000..8f4830e --- /dev/null +++ b/tests/unit/interfaces/mcp/mcp-tool-contracts.test.ts @@ -0,0 +1,471 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { describe, expect, it, vi } from 'vitest'; +import type { TaskApplication } 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 { TaskValidationError } from '../../../../src/domain/task/task-errors.js'; +import { createMcpServer } from '../../../../src/interfaces/mcp/create-mcp-server.js'; +import { + sessionCapturesOutputSchema, + taskCaptureOutputSchema, + taskFindSimilarOutputSchema, + taskGetOutputSchema, + taskListOutputSchema, +} from '../../../../src/interfaces/mcp/schemas/read-tool-schemas.js'; +import { ZodError } from 'zod'; + +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 taskApplication(overrides: Partial = {}): TaskApplication { + const result = task(); + return { + create: vi.fn(() => result), + get: vi.fn(() => result), + list: vi.fn(() => [result]), + findSimilar: vi.fn(() => []), + listSessionCaptures: vi.fn(() => [result]), + edit: vi.fn(), + moveToInbox: vi.fn(), + activate: vi.fn(), + start: vi.fn(), + moveToBacklog: vi.fn(), + complete: vi.fn(), + archive: vi.fn(), + ...overrides, + } as unknown as TaskApplication; +} + +async function createConnectedMcpTestServer(application: TaskApplication) { + const server = createMcpServer(application); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'mcp-contract-test', version: '1.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + client, + close: async () => { + await client.close(); + await server.close(); + }, + }; +} + +function structured(result: unknown): Record { + return (result as { structuredContent: Record }).structuredContent; +} + +function expectCompatibilityText(result: unknown): void { + const response = result as { + content: Array<{ type: string; text?: string }>; + structuredContent: Record; + }; + expect(response.content).toHaveLength(1); + expect(response.content[0]).toEqual({ + type: 'text', + text: JSON.stringify(response.structuredContent), + }); +} + +const executionErrors = [ + [new ZodError([]), 'VALIDATION_ERROR'], + [ + new InvalidTaskRequestError('SQLITE_CONSTRAINT /tmp/relay.db super-secret-token'), + 'VALIDATION_ERROR', + ], + [ + new TaskValidationError('title', 'SQLITE_CONSTRAINT /tmp/relay.db super-secret-token'), + 'VALIDATION_ERROR', + ], + [new TaskNotFoundError('SQLITE_CONSTRAINT /tmp/relay.db super-secret-token'), 'NOT_FOUND'], + [new TaskPersistenceError('SQLITE_CONSTRAINT /tmp/relay.db super-secret-token'), 'STORAGE_ERROR'], + [new Error('SQLITE_CONSTRAINT /tmp/relay.db super-secret-token'), 'INTERNAL_ERROR'], +] as const; + +describe('MCP task tool contracts', () => { + it('preserves capture provenance, checks duplicates before creation, and returns advisory warnings', async () => { + const calls: string[] = []; + let capturedInput: unknown; + const application = taskApplication({ + findSimilar: vi.fn(() => { + calls.push('findSimilar'); + return [task({ id: 'existing' })]; + }), + create: vi.fn((input) => { + calls.push('create'); + capturedInput = input; + return task({ sourceContext: 'issue-26' }); + }), + }); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ + name: 'task_capture', + arguments: { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + sourceContext: 'issue-26', + }, + }); + taskCaptureOutputSchema.parse(structured(result)); + expectCompatibilityText(result); + expect(calls).toEqual(['findSimilar', 'create']); + expect(capturedInput).toMatchObject({ + creator: { type: 'AGENT', name: 'Codex' }, + sessionId: 'session-a', + sourceContext: 'issue-26', + }); + expect(capturedInput).not.toHaveProperty('status'); + expect(structured(result)).toMatchObject({ + data: { change: { action: 'CREATED' } }, + warnings: [{ code: 'POSSIBLE_DUPLICATE', candidates: [{ id: 'existing' }] }], + }); + } finally { + await close(); + } + }); + + it('returns no capture warning when the advisory lookup has no candidates', async () => { + const { client, close } = await createConnectedMcpTestServer(taskApplication()); + try { + const result = await client.callTool({ + name: 'task_capture', + arguments: { title: 'Prepare release', createdByName: 'Codex', sessionId: 'session-a' }, + }); + expect(structured(result)).toMatchObject({ warnings: [] }); + expect((result as { isError?: boolean }).isError).not.toBe(true); + } finally { + await close(); + } + }); + + it('uses SDK invalid-parameter responses without invoking the application for unsafe input', async () => { + const application = taskApplication(); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ + name: 'task_capture', + arguments: { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + creator: { type: 'HUMAN' }, + }, + }); + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).toContain('-32602'); + expect(application.findSimilar).not.toHaveBeenCalled(); + expect(application.create).not.toHaveBeenCalled(); + } finally { + await close(); + } + }); + + it.each([ + ['caller-controlled status', { status: 'DONE' }], + ['caller-controlled creator', { creator: { type: 'HUMAN' } }], + ['caller-controlled creator type', { createdByType: 'HUMAN' }], + ['unknown property', { unrelated: true }], + ['empty title', { title: ' ' }], + ['title above the maximum', { title: 'x'.repeat(301) }], + ['creator name above the maximum', { createdByName: 'x'.repeat(101) }], + ['malformed session id', { sessionId: 'not a valid session' }], + ['workspace above the maximum', { workspace: 'x'.repeat(256) }], + ['source context above the maximum', { sourceContext: 'x'.repeat(1001) }], + ])('rejects capture %s before application execution', async (_name, invalidArgument) => { + const application = taskApplication(); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ + name: 'task_capture', + arguments: { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + ...invalidArgument, + }, + }); + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).toContain('-32602'); + expect(application.findSimilar).not.toHaveBeenCalled(); + expect(application.create).not.toHaveBeenCalled(); + } finally { + await close(); + } + }); + + it.each([ + ['missing all required fields', {}], + ['missing title', { createdByName: 'Codex', sessionId: 'session-a' }], + ['missing creator name', { title: 'Prepare release', sessionId: 'session-a' }], + ['missing session id', { title: 'Prepare release', createdByName: 'Codex' }], + [ + 'empty creator name', + { title: 'Prepare release', createdByName: ' ', sessionId: 'session-a' }, + ], + [ + 'empty description', + { title: 'Prepare release', createdByName: 'Codex', sessionId: 'session-a', description: '' }, + ], + [ + 'description above the maximum', + { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + description: 'x'.repeat(10001), + }, + ], + [ + 'invalid priority', + { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + priority: 'URGENT', + }, + ], + [ + 'empty workspace', + { title: 'Prepare release', createdByName: 'Codex', sessionId: 'session-a', workspace: '' }, + ], + [ + 'empty source context', + { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + sourceContext: '', + }, + ], + ])('rejects capture %s before application execution', async (_name, arguments_) => { + const application = taskApplication(); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ name: 'task_capture', arguments: arguments_ }); + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).toContain('-32602'); + expect(application.findSimilar).not.toHaveBeenCalled(); + expect(application.create).not.toHaveBeenCalled(); + } finally { + await close(); + } + }); + + it('validates every successful read-tool output and compatibility text', async () => { + const result = task(); + const application = taskApplication({ + findSimilar: vi.fn(() => [result]), + listSessionCaptures: vi.fn(() => [result]), + }); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const get = await client.callTool({ name: 'task_get', arguments: { taskId: result.id } }); + const list = await client.callTool({ + name: 'task_list', + arguments: { statuses: ['INBOX'], workspace: ' relay ', limit: 1 }, + }); + const similar = await client.callTool({ + name: 'task_find_similar', + arguments: { title: result.title, workspace: 'relay', limit: 5 }, + }); + const session = await client.callTool({ + name: 'session_captures_list', + arguments: { sessionId: 'session-a' }, + }); + taskGetOutputSchema.parse(structured(get)); + taskListOutputSchema.parse(structured(list)); + taskFindSimilarOutputSchema.parse(structured(similar)); + sessionCapturesOutputSchema.parse(structured(session)); + for (const response of [get, list, similar, session]) expectCompatibilityText(response); + expect(application.list).toHaveBeenCalledWith({ + statuses: ['INBOX'], + workspace: 'relay', + limit: 1, + }); + expect(application.findSimilar).toHaveBeenCalledWith({ + title: result.title, + workspace: 'relay', + limit: 5, + }); + } finally { + await close(); + } + }); + + it('preserves list defaults, explicit null workspace, and normalized workspace filters', async () => { + const application = taskApplication(); + const { client, close } = await createConnectedMcpTestServer(application); + try { + await client.callTool({ name: 'task_list', arguments: {} }); + await client.callTool({ name: 'task_list', arguments: { workspace: null } }); + await client.callTool({ name: 'task_list', arguments: { workspace: ' relay ' } }); + expect(application.list).toHaveBeenNthCalledWith(1, { + statuses: ['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED'], + limit: 100, + }); + expect(application.list).toHaveBeenNthCalledWith(2, { + statuses: ['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED'], + workspace: null, + limit: 100, + }); + expect(application.list).toHaveBeenNthCalledWith(3, { + statuses: ['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED'], + workspace: 'relay', + limit: 100, + }); + } finally { + await close(); + } + }); + + it('maps a missing task to the stable read-tool error envelope', async () => { + const application = taskApplication({ + get: vi.fn(() => { + throw new TaskNotFoundError('missing'); + }), + }); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ name: 'task_get', arguments: { taskId: 'missing' } }); + expect(result).toMatchObject({ + isError: true, + structuredContent: { + schemaVersion: 1, + error: { code: 'NOT_FOUND', message: 'Task was not found.' }, + }, + }); + expectCompatibilityText(result); + } finally { + await close(); + } + }); + + it('returns stable exact and normalized similar-task reasons', async () => { + const exact = task({ id: 'exact', title: 'Prepare release' }); + const normalized = task({ id: 'normalized', title: 'Prepare release!!!' }); + const application = taskApplication({ findSimilar: vi.fn(() => [exact, normalized]) }); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ + name: 'task_find_similar', + arguments: { title: 'Prepare release' }, + }); + expect(structured(result)).toMatchObject({ + data: { + candidates: [ + { task: { id: 'exact' }, matchReason: 'EXACT_TITLE' }, + { task: { id: 'normalized' }, matchReason: 'NORMALIZED_TITLE' }, + ], + }, + }); + expectCompatibilityText(result); + } finally { + await close(); + } + }); + + it.each([ + ['task_get', { taskId: '' }, 'get'], + ['task_get', {}, 'get'], + ['task_get', { taskId: 'x'.repeat(101) }, 'get'], + ['task_get', { taskId: 'task-1', unknown: true }, 'get'], + ['task_list', { statuses: [] }, 'list'], + ['task_list', { statuses: ['INBOX', 'INBOX'] }, 'list'], + ['task_list', { statuses: ['UNKNOWN'] }, 'list'], + ['task_list', { limit: 0 }, 'list'], + ['task_list', { limit: 101 }, 'list'], + ['task_list', { limit: 1.5 }, 'list'], + ['task_list', { workspace: '' }, 'list'], + ['task_list', { workspace: 'x'.repeat(256) }, 'list'], + ['task_list', { unknown: true }, 'list'], + ['task_find_similar', { title: '' }, 'findSimilar'], + ['task_find_similar', {}, 'findSimilar'], + ['task_find_similar', { title: 'x'.repeat(301) }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', workspace: '' }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', workspace: 'x'.repeat(256) }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', limit: 0 }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', limit: 6 }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', limit: 1.5 }, 'findSimilar'], + ['task_find_similar', { title: 'Prepare release', unknown: true }, 'findSimilar'], + ['session_captures_list', {}, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'bad session' }, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'x'.repeat(129) }, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'session-a', limit: 0 }, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'session-a', limit: 101 }, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'session-a', limit: 1.5 }, 'listSessionCaptures'], + ['session_captures_list', { sessionId: 'session-a', unknown: true }, 'listSessionCaptures'], + ] as const)( + 'rejects invalid %s arguments before calling %s', + async (name, arguments_, method) => { + const application = taskApplication(); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ name, arguments: arguments_ }); + expect(result).toMatchObject({ isError: true }); + expect(JSON.stringify(result)).toContain('-32602'); + expect(application[method]).not.toHaveBeenCalled(); + } finally { + await close(); + } + }, + ); + + it.each([ + [ + 'task_capture', + { title: 'Prepare release', createdByName: 'Codex', sessionId: 'session-a' }, + 'findSimilar', + ], + ['task_list', {}, 'list'], + ['task_get', { taskId: 'task-1' }, 'get'], + ['task_find_similar', { title: 'Prepare release' }, 'findSimilar'], + ['session_captures_list', { sessionId: 'session-a' }, 'listSessionCaptures'], + ] as const)( + 'maps every stable execution error category for %s without leaking internals', + async (name, arguments_, method) => { + for (const [error, code] of executionErrors) { + const application = taskApplication({ + [method]: vi.fn(() => { + throw error; + }), + } as Partial); + const { client, close } = await createConnectedMcpTestServer(application); + try { + const result = await client.callTool({ name, arguments: arguments_ }); + expect(result).toMatchObject({ + isError: true, + structuredContent: { schemaVersion: 1, error: { code } }, + }); + expectCompatibilityText(result); + expect(JSON.stringify(result)).not.toMatch(/SQLITE|relay\.db|super-secret-token|stack/i); + } finally { + await close(); + } + } + }, + ); +}); diff --git a/tests/unit/interfaces/mcp/run-mcp-server.test.ts b/tests/unit/interfaces/mcp/run-mcp-server.test.ts new file mode 100644 index 0000000..6b9e8d4 --- /dev/null +++ b/tests/unit/interfaces/mcp/run-mcp-server.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TaskApplication } from '../../../../src/application/tasks/task-application.js'; +import { runMcpServer } from '../../../../src/interfaces/mcp/run-mcp-server.js'; + +describe('runMcpServer', () => { + it('closes server before runtime exactly once after repeated signals', async () => { + const events: string[] = []; + let signal: (() => void) | undefined; + const runtime = { + taskApplication: {} as TaskApplication, + close: vi.fn(() => events.push('runtime.close')), + }; + const server = { + connect: vi.fn(async () => {}), + close: vi.fn(async () => { + events.push('server.close'); + }), + }; + await runMcpServer({ + createRuntime: () => runtime, + createServer: () => server, + createTransport: () => ({}), + onSignal: (_name, handler) => { + signal = handler; + }, + reportFatal: vi.fn(), + }); + signal?.(); + signal?.(); + await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledOnce()); + expect(server.close).toHaveBeenCalledOnce(); + expect(events).toEqual(['server.close', 'runtime.close']); + }); + + it('reports a failed connect as a non-zero startup outcome after closing in order', async () => { + const events: string[] = []; + const connectFailure = new Error('connect failed'); + const runtime = { + taskApplication: {} as TaskApplication, + close: vi.fn(() => { + events.push('runtime.close'); + }), + }; + const server = { + connect: vi.fn(async () => { + throw connectFailure; + }), + close: vi.fn(async () => { + events.push('server.close'); + }), + }; + const reportFatal = vi.fn(); + + const started = await runMcpServer({ + createRuntime: () => runtime, + createServer: () => server, + createTransport: () => ({}), + onSignal: vi.fn(), + reportFatal, + }); + + expect(started).toBe(false); + expect(reportFatal).toHaveBeenCalledWith(connectFailure); + expect(events).toEqual(['server.close', 'runtime.close']); + }); + + it('closes the runtime when server creation fails', async () => { + const runtime = { taskApplication: {} as TaskApplication, close: vi.fn() }; + const creationFailure = new Error('server creation failed'); + const reportFatal = vi.fn(); + + const started = await runMcpServer({ + createRuntime: () => runtime, + createServer: () => { + throw creationFailure; + }, + createTransport: () => ({}), + onSignal: vi.fn(), + reportFatal, + }); + + expect(started).toBe(false); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(reportFatal).toHaveBeenCalledWith(creationFailure); + }); + + it('still closes the runtime when server shutdown fails', async () => { + let signal: (() => void) | undefined; + const serverCloseFailure = new Error('server close failed'); + const runtime = { taskApplication: {} as TaskApplication, close: vi.fn() }; + const server = { + connect: vi.fn(async () => {}), + close: vi.fn(async () => { + throw serverCloseFailure; + }), + }; + const reportFatal = vi.fn(); + await runMcpServer({ + createRuntime: () => runtime, + createServer: () => server, + createTransport: () => ({}), + onSignal: (_name, handler) => { + signal = handler; + }, + reportFatal, + }); + + signal?.(); + await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledOnce()); + expect(server.close).toHaveBeenCalledOnce(); + expect(reportFatal).toHaveBeenCalledWith(serverCloseFailure); + }); +});