Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Database and safe development data

Relay uses a local SQLite database containing task data. The default database file is:
Expand Down Expand Up @@ -87,15 +95,15 @@ 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
```

Adapters call application services. The React application calls the loopback HTTP API only; it does not import SQLite or domain code. The task domain does not depend on SQLite, HTTP, MCP, React, or Zod. Relay has no remote binding or authentication.

## 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

Expand Down
6 changes: 3 additions & 3 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down
69 changes: 69 additions & 0 deletions docs/superpowers/plans/2026-07-26-issue-26-mcp-tools.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/application/tasks/task-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion src/application/tasks/use-cases/list-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'Tasks could not be listed.',
);
}
27 changes: 21 additions & 6 deletions src/database/tasks/sqlite-task-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class SqliteTaskRepository implements TaskRepository {
private readonly insertStatement: Database.Statement<TaskParameters>;
private readonly findStatement: Database.Statement<[string], TaskRow>;
private readonly updateStatement: Database.Statement<TaskUpdateParameters>;
private readonly listStatements = new Map<number, Database.Statement<unknown[], TaskRow>>();
private readonly listStatements = new Map<string, Database.Statement<unknown[], TaskRow>>();
private readonly sessionCaptureStatement: Database.Statement<[string, number], TaskRow>;
private readonly similarStatements = new Map<boolean, Database.Statement<unknown[], TaskRow>>();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -197,8 +200,12 @@ export class SqliteTaskRepository implements TaskRepository {
}
}

private getListStatement(statusCount: number): Database.Statement<unknown[], TaskRow> {
const existing = this.listStatements.get(statusCount);
private getListStatement(
statusCount: number,
filteredByWorkspace: boolean,
): Database.Statement<unknown[], TaskRow> {
const key = `${statusCount}:${filteredByWorkspace}`;
const existing = this.listStatements.get(key);
if (existing !== undefined) {
return existing;
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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.');
}
Expand Down
8 changes: 7 additions & 1 deletion src/interfaces/contracts/task-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
7 changes: 6 additions & 1 deletion src/interfaces/mcp/create-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
47 changes: 12 additions & 35 deletions src/interfaces/mcp/main.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
const server = createMcpServer();
const transport = new StdioServerTransport();
let shuttingDown = false;

const shutdown = async (signal: 'SIGINT' | 'SIGTERM'): Promise<void> => {
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();
21 changes: 21 additions & 0 deletions src/interfaces/mcp/mapping/mcp-errors.ts
Original file line number Diff line number Diff line change
@@ -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.');
}
18 changes: 18 additions & 0 deletions src/interfaces/mcp/mapping/mcp-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { CONTRACT_SCHEMA_VERSION } from '../../contracts/contract-version.js';

export function mcpSuccess(data: Record<string, unknown>, 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) }],
};
}
Loading
Loading