docs: add design spec and implementation plan for MCP server assembly
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
# MCP Stdio Server Assembly & Integration 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:** Assemble the OpenProject MCP server with `StdioServerTransport`, scoped request context execution, read-only mode enforcement, and end-to-end client verification.
|
||||
|
||||
**Architecture:** A modular server factory (`src/server.ts`) initializes `McpServer` and `OpenProjectClient`, registers all 10 tools with execution wrappers running inside `runWithContext`, connects to `StdioServerTransport` in CLI entrypoint (`src/index.ts`), and redirects all diagnostics to `stderr`.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `@modelcontextprotocol/sdk` (McpServer, StdioServerTransport, InMemoryTransport, Client), Zod, `bun:test`.
|
||||
|
||||
**Spec:** [docs/specs/2026-09-06-mcp-server-design.md](file:///home/user/openproject-mcp/docs/specs/2026-09-06-mcp-server-design.md)
|
||||
|
||||
## Global Constraints
|
||||
- Bun runtime (>= 1.3), `bun test` for verification.
|
||||
- Strict TypeScript (`strict: true`), no `any` (use explicit Zod types or `unknown`).
|
||||
- Stateless execution: resolve ambient client via `RequestContext` (`runWithContext`).
|
||||
- All server diagnostics and logging must use `console.error` (never `console.log` on stdout).
|
||||
- Read-only mode must omit mutating tools from manifest and reject unauthorized operations with `SERVER_READ_ONLY`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Execution Wrapper & Read-Only Guard in Tool Registration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/tools/common.ts`
|
||||
- Modify: `src/tools/index.ts`
|
||||
- Modify: `tests/tools.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `src/tools/common.ts`, `src/tools/index.ts`
|
||||
- Produces:
|
||||
- `RegisterToolOptions`: `{ wrapExecute?: (fn: () => Promise<McpToolResponse>, tool: ToolDefinition<any>, args: Record<string, unknown>) => Promise<McpToolResponse> }`
|
||||
- `registerTool(server, tool, options)`: passes execution through `wrapExecute` if supplied.
|
||||
- `registerAllTools(server, options)`: forwards `wrapExecute` to each tool registration.
|
||||
|
||||
- [ ] **Step 1: Write failing tests for tool execution wrapping**
|
||||
|
||||
In `tests/tools.test.ts`:
|
||||
```typescript
|
||||
describe("Tool Execution Wrapper", () => {
|
||||
test("registerTool passes execution through wrapExecute when provided", async () => {
|
||||
const server = new McpServer({ name: "test", version: "1" });
|
||||
let wrapperCalled = false;
|
||||
const dummyTool: ToolDefinition = {
|
||||
name: "wrapped_tool",
|
||||
description: "A wrapped tool",
|
||||
readOnly: true,
|
||||
execute: async () => ({ content: [{ type: "text", text: "success" }] }),
|
||||
};
|
||||
|
||||
registerTool(server, dummyTool, {
|
||||
wrapExecute: async (fn) => {
|
||||
wrapperCalled = true;
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
|
||||
const registered = (server as unknown as { _registeredTools: Record<string, any> })._registeredTools["wrapped_tool"];
|
||||
const result = await registered.handler({});
|
||||
expect(wrapperCalled).toBe(true);
|
||||
expect(result.content[0].text).toBe("success");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test tests/tools.test.ts`
|
||||
Expected: FAIL (options parameter or wrapExecute behavior not implemented).
|
||||
|
||||
- [ ] **Step 3: Implement wrapExecute in `src/tools/common.ts` and `src/tools/index.ts`**
|
||||
|
||||
In `src/tools/common.ts`:
|
||||
Update `registerTool`:
|
||||
```typescript
|
||||
export interface RegisterToolOptions<
|
||||
TShape extends ZodRawShape = ZodRawShape,
|
||||
TArgs = Record<string, unknown>,
|
||||
> {
|
||||
wrapExecute?: (
|
||||
fn: () => Promise<McpToolResponse>,
|
||||
tool: ToolDefinition<TShape, TArgs>,
|
||||
args: TArgs
|
||||
) => Promise<McpToolResponse>;
|
||||
}
|
||||
|
||||
export function registerTool<
|
||||
TShape extends ZodRawShape = ZodRawShape,
|
||||
TArgs = Record<string, unknown>,
|
||||
>(
|
||||
server: McpServer,
|
||||
tool: ToolDefinition<TShape, TArgs>,
|
||||
options?: RegisterToolOptions<TShape, TArgs>
|
||||
): void {
|
||||
const executeFn = (args: TArgs) => {
|
||||
if (options?.wrapExecute) {
|
||||
return options.wrapExecute(() => tool.execute(args), tool, args);
|
||||
}
|
||||
return tool.execute(args);
|
||||
};
|
||||
|
||||
if (tool.parameters) {
|
||||
server.tool(tool.name, tool.description, tool.parameters, async (args) => {
|
||||
return executeFn(args as unknown as TArgs);
|
||||
});
|
||||
} else {
|
||||
server.tool(tool.name, tool.description, async () => {
|
||||
return executeFn({} as unknown as TArgs);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `src/tools/index.ts`:
|
||||
Update `RegisterToolsOptions`:
|
||||
```typescript
|
||||
export interface RegisterToolsOptions {
|
||||
readOnly?: boolean;
|
||||
tools?: AnyToolDefinition[];
|
||||
wrapExecute?: (
|
||||
fn: () => Promise<McpToolResponse>,
|
||||
tool: AnyToolDefinition,
|
||||
args: Record<string, unknown>
|
||||
) => Promise<McpToolResponse>;
|
||||
}
|
||||
```
|
||||
And pass `options` to each tool registration.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test tests/tools.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tools/common.ts src/tools/index.ts tests/tools.test.ts
|
||||
git commit -m "feat(tools): add execution wrapper support to tool registration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Server Factory Implementation (`src/server.ts`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server.ts`
|
||||
- Create: `tests/mcp-server.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `src/config/index.ts`, `src/client/api-client.ts`, `src/context.ts`, `src/tools/index.ts`, `@modelcontextprotocol/sdk`
|
||||
- Produces:
|
||||
- `OpenProjectMcpServer`: `{ server: McpServer; client: OpenProjectClient; start: (transport?: Transport) => Promise<void>; stop: () => Promise<void> }`
|
||||
- `createServer(config: AppConfig): OpenProjectMcpServer`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for server factory**
|
||||
|
||||
In `tests/mcp-server.test.ts`:
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { createServer } from "../src/server";
|
||||
|
||||
describe("MCP Server Factory", () => {
|
||||
test("creates server and lists all 10 tools via InMemoryTransport", async () => {
|
||||
const config = {
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "test-key",
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const mcpServer = createServer(config);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "test-client", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools).toHaveLength(10);
|
||||
const names = toolsResult.tools.map((t) => t.name);
|
||||
expect(names).toContain("openproject_list_projects");
|
||||
expect(names).toContain("openproject_get_work_package");
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test tests/mcp-server.test.ts`
|
||||
Expected: FAIL with module `../src/server` not found.
|
||||
|
||||
- [ ] **Step 3: Implement `src/server.ts`**
|
||||
|
||||
In `src/server.ts`:
|
||||
```typescript
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { AppConfig } from "./config";
|
||||
import { OpenProjectClient } from "./client/api-client";
|
||||
import { runWithContext } from "./context";
|
||||
import { registerAllTools } from "./tools";
|
||||
import { formatToolError } from "./tools/common";
|
||||
import { OpenProjectError } from "./client/errors";
|
||||
|
||||
export interface OpenProjectMcpServer {
|
||||
server: McpServer;
|
||||
client: OpenProjectClient;
|
||||
start: (transport?: Transport) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SERVER_NAME = "openproject-mcp";
|
||||
export const SERVER_VERSION = "0.1.0";
|
||||
|
||||
export function createServer(config: AppConfig): OpenProjectMcpServer {
|
||||
const server = new McpServer({
|
||||
name: SERVER_NAME,
|
||||
version: SERVER_VERSION,
|
||||
});
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
|
||||
// Register all tools with ambient context execution and read-only guards
|
||||
registerAllTools(server, {
|
||||
readOnly: config.readOnly,
|
||||
wrapExecute: async (fn, tool) => {
|
||||
if (config.readOnly && !tool.readOnly) {
|
||||
return formatToolError(
|
||||
new OpenProjectError(
|
||||
"SERVER_READ_ONLY",
|
||||
"Operation rejected. OpenProject MCP server is running in read-only mode."
|
||||
)
|
||||
);
|
||||
}
|
||||
return runWithContext({ client, isReadOnly: config.readOnly }, fn);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
client,
|
||||
start: async (transport?: Transport) => {
|
||||
const activeTransport = transport ?? new StdioServerTransport();
|
||||
await server.connect(activeTransport);
|
||||
console.error(`[${SERVER_NAME}] Server started (readOnly=${config.readOnly})`);
|
||||
},
|
||||
stop: async () => {
|
||||
await server.close();
|
||||
console.error(`[${SERVER_NAME}] Server stopped`);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test tests/mcp-server.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server.ts tests/mcp-server.test.ts
|
||||
git commit -m "feat(server): implement MCP server factory and transport lifecycle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Read-Only Mode Enforcement & Guard Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/read-only.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `src/server.ts`, `src/config/index.ts`, `src/tools/common.ts`, `@modelcontextprotocol/sdk`
|
||||
- Produces: Complete verification of manifest filtering and execution guards under read-only mode.
|
||||
|
||||
- [ ] **Step 1: Write read-only mode verification tests**
|
||||
|
||||
In `tests/read-only.test.ts`:
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { createServer } from "../src/server";
|
||||
import { parseConfig } from "../src/config";
|
||||
import type { ToolDefinition } from "../src/tools/common";
|
||||
import { allTools } from "../src/tools";
|
||||
|
||||
describe("Read-Only Mode Enforcement", () => {
|
||||
test("parseConfig parses read-only flags from env and argv", () => {
|
||||
const configEnv = parseConfig({
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080",
|
||||
OPENPROJECT_API_KEY: "key",
|
||||
OPENPROJECT_READ_ONLY: "true",
|
||||
}, []);
|
||||
expect(configEnv.readOnly).toBe(true);
|
||||
|
||||
const configArg = parseConfig({
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080",
|
||||
OPENPROJECT_API_KEY: "key",
|
||||
}, ["--read-only"]);
|
||||
expect(configArg.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("read-only server lists all 10 Phase 1 tools (all are readOnly: true)", async () => {
|
||||
const mcpServer = createServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "test-key",
|
||||
readOnly: true,
|
||||
});
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "client", version: "1" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
expect(toolsResult.tools).toHaveLength(10);
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
|
||||
test("read-only mode filters out mutating tools from tools/list", async () => {
|
||||
const syntheticMutatingTool: ToolDefinition = {
|
||||
name: "openproject_create_work_package_synthetic",
|
||||
description: "Synthetic mutating tool",
|
||||
readOnly: false,
|
||||
execute: async () => ({ content: [{ type: "text", text: "created" }] }),
|
||||
};
|
||||
|
||||
const mcpServer = createServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "test-key",
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
// Manually register a mutating tool through the server to test filtering
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "client", version: "1" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const names = toolsResult.tools.map((t) => t.name);
|
||||
expect(names).not.toContain("openproject_create_work_package_synthetic");
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
|
||||
test("execution guard returns SERVER_READ_ONLY if mutating tool is called in read-only mode", async () => {
|
||||
let executionAttempted = false;
|
||||
const syntheticMutatingTool: ToolDefinition = {
|
||||
name: "openproject_synthetic_write",
|
||||
description: "Write tool",
|
||||
readOnly: false,
|
||||
execute: async () => {
|
||||
executionAttempted = true;
|
||||
return { content: [{ type: "text", text: "written" }] };
|
||||
},
|
||||
};
|
||||
|
||||
const mcpServer = createServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "test-key",
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
// Register with mutating tool included
|
||||
const { registerTool } = await import("../src/tools/common");
|
||||
registerTool(mcpServer.server, syntheticMutatingTool, {
|
||||
wrapExecute: async (fn, tool) => {
|
||||
if (true && !tool.readOnly) {
|
||||
const { OpenProjectError } = await import("../src/client/errors");
|
||||
const { formatToolError } = await import("../src/tools/common");
|
||||
return formatToolError(
|
||||
new OpenProjectError("SERVER_READ_ONLY", "Operation rejected. OpenProject MCP server is running in read-only mode.")
|
||||
);
|
||||
}
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "client", version: "1" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const callRes = await client.callTool({
|
||||
name: "openproject_synthetic_write",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(callRes.isError).toBe(true);
|
||||
expect((callRes.content[0] as { text: string }).text).toContain("SERVER_READ_ONLY");
|
||||
expect(executionAttempted).toBe(false);
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `bun test tests/read-only.test.ts`
|
||||
Expected: PASS (4 pass).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/read-only.test.ts
|
||||
git commit -m "feat(server): add read-only mode enforcement and execution guard tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CLI Entrypoint, End-to-End Live Tool Calling & TODO Checkoff
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/index.ts`
|
||||
- Modify: `tests/mcp-server.test.ts`
|
||||
- Modify: `docs/TODO.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `src/server.ts`, `src/config/index.ts`, Live OpenProject 17 container
|
||||
- Produces: Executable stdio server CLI, full E2E tool verification, and completed Phase 1 roadmap.
|
||||
|
||||
- [ ] **Step 1: Add live container E2E tool calling tests to `tests/mcp-server.test.ts`**
|
||||
|
||||
In `tests/mcp-server.test.ts`:
|
||||
```typescript
|
||||
describe("End-to-End Live Tool Calling over MCP Client", () => {
|
||||
const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
|
||||
const apiKey = process.env.OPENPROJECT_API_KEY || "";
|
||||
|
||||
test("calls openproject_list_projects and openproject_get_work_package via Client", async () => {
|
||||
const mcpServer = createServer({ baseUrl, apiKey, readOnly: false });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "test-runner", version: "1" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// Call openproject_list_projects
|
||||
const projRes = await client.callTool({
|
||||
name: "openproject_list_projects",
|
||||
arguments: { pageSize: 5 },
|
||||
});
|
||||
expect(projRes.isError).toBeUndefined();
|
||||
const projData = JSON.parse((projRes.content[0] as { text: string }).text);
|
||||
expect(projData.projects.length).toBeGreaterThan(0);
|
||||
|
||||
// Call openproject_get_work_package
|
||||
const wpRes = await client.callTool({
|
||||
name: "openproject_get_work_package",
|
||||
arguments: { workPackageId: 38 },
|
||||
});
|
||||
expect(wpRes.isError).toBeUndefined();
|
||||
const wpData = JSON.parse((wpRes.content[0] as { text: string }).text);
|
||||
expect(wpData.id).toBe(38);
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
|
||||
test("tool error propagation returns isError: true without crashing client", async () => {
|
||||
const mcpServer = createServer({ baseUrl, apiKey, readOnly: false });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.start(serverTransport);
|
||||
|
||||
const client = new Client({ name: "test-runner", version: "1" });
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const errRes = await client.callTool({
|
||||
name: "openproject_get_project",
|
||||
arguments: { projectId: 999999 },
|
||||
});
|
||||
expect(errRes.isError).toBe(true);
|
||||
expect((errRes.content[0] as { text: string }).text).toContain("OPENPROJECT_NOT_FOUND");
|
||||
|
||||
await client.close();
|
||||
await mcpServer.stop();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement production CLI entrypoint in `src/index.ts`**
|
||||
|
||||
In `src/index.ts`:
|
||||
```typescript
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* openproject-mcp - Model Context Protocol Server for OpenProject
|
||||
*/
|
||||
|
||||
import { parseConfig } from "./config";
|
||||
import { createServer } from "./server";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const config = parseConfig(process.env, process.argv);
|
||||
const mcpServer = createServer(config);
|
||||
|
||||
// Bind graceful termination
|
||||
const shutdown = async () => {
|
||||
console.error("\n[openproject-mcp] Received termination signal, shutting down...");
|
||||
await mcpServer.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
// Start server over stdio transport
|
||||
await mcpServer.start();
|
||||
} catch (error) {
|
||||
console.error("[openproject-mcp] Fatal startup error:", error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Only execute when invoked directly as a script
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { createServer } from "./server";
|
||||
export { SERVER_NAME, SERVER_VERSION } from "./server";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: All tests pass across all 5 test files (`smoke`, `client`, `services`, `tools`, `mcp-server`, `read-only`).
|
||||
|
||||
- [ ] **Step 4: Update `docs/TODO.md`**
|
||||
|
||||
Mark Task 4 as completed in `docs/TODO.md`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts tests/mcp-server.test.ts docs/TODO.md
|
||||
git commit -m "feat(server): wire CLI entrypoint, live E2E tool calling, and mark Task 4 complete in TODO.md"
|
||||
```
|
||||
@@ -0,0 +1,155 @@
|
||||
# Design Specification: MCP Stdio Server Assembly & Integration
|
||||
|
||||
**Date**: 2026-09-06
|
||||
**Status**: Approved
|
||||
**Scope**: Phase 1 Task 4 - MCP Stdio Server Assembly & Integration
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This specification defines the assembly, transport binding, request lifecycle management, read-only enforcement, and logging safeguards for the `openproject-mcp` stdio server.
|
||||
|
||||
As the final milestone of Phase 1 (Read/Browse Implementation), Task 4 integrates the configuration layer (`src/config/`), client and request context (`src/client/`, `src/context.ts`), and MCP tools (`src/tools/`) into an executable Model Context Protocol server communicating over standard input/output (`StdioServerTransport`) using `@modelcontextprotocol/sdk`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Design Principles
|
||||
|
||||
### 2.1 Server Factory Pattern (`src/server.ts`)
|
||||
To ensure testability and separation from process lifecycle concerns, the server is instantiated via a factory function:
|
||||
|
||||
```typescript
|
||||
export interface OpenProjectMcpServer {
|
||||
server: McpServer;
|
||||
client: OpenProjectClient;
|
||||
start: (transport?: Transport) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createServer(config: AppConfig): OpenProjectMcpServer;
|
||||
```
|
||||
|
||||
- **Configuration Injection**: Accepts validated `AppConfig` (`baseUrl`, `apiKey`, `readOnly`).
|
||||
- **Scoped Client Lifecycle**: Instantiates an isolated `OpenProjectClient` for the session.
|
||||
- **Context Binding**: Automatically wraps each incoming tool execution inside `runWithContext({ client, isReadOnly: config.readOnly }, ...)` so domain services and tools always resolve the active client without global state.
|
||||
- **Pluggable Transport**: Default to `StdioServerTransport` for production, with support for `InMemoryTransport` in unit and integration tests.
|
||||
|
||||
### 2.2 CLI Entrypoint (`src/index.ts`)
|
||||
The CLI entrypoint:
|
||||
1. Validates environment variables and CLI arguments (`parseConfig(process.env, process.argv)`).
|
||||
2. Initializes the server via `createServer(config)`.
|
||||
3. Binds graceful termination handlers (`SIGINT`, `SIGTERM`).
|
||||
4. Connects to `StdioServerTransport` and starts listening.
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------+
|
||||
| MCP Client Host |
|
||||
| (Claude Desktop, Cursor, Antigravity) |
|
||||
+-------------------------------------------------------------+
|
||||
|
|
||||
stdio (stdin / stdout)
|
||||
v
|
||||
+-------------------------------------------------------------+
|
||||
| src/index.ts / src/server.ts |
|
||||
| - Parse AppConfig (OPENPROJECT_BASE_URL, API_KEY, ReadOnly)|
|
||||
| - Instantiate McpServer & OpenProjectClient |
|
||||
| - StdioServerTransport |
|
||||
| - Execution Wrapper: runWithContextStorage |
|
||||
+-------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+-------------------------------------------------------------+
|
||||
| src/tools/ (10 Tools) |
|
||||
| - Tool Definitions & Zod Parameter Validation |
|
||||
| - Error Sanitization (formatToolSuccess / formatToolError) |
|
||||
| - Read-Only Execution Guard |
|
||||
+-------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+-------------------------------------------------------------+
|
||||
| src/services/ & src/client/ |
|
||||
| - projects, work-packages, queries, metadata |
|
||||
| - Native fetch to OpenProject API v3 (Basic Auth) |
|
||||
+-------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2.3 Read-Only Mode Enforcement
|
||||
Per ADR-005 and AGENTS.md requirements:
|
||||
1. **Tool Manifest Filtering**: When `config.readOnly` is `true`, mutating tools are excluded from registration so LLM clients never receive them in `tools/list`.
|
||||
2. **Runtime Execution Guard**: In addition to manifest omission, any tool call executed while `isReadOnly` is `true` that attempts a non-read-only action is rejected with error code `SERVER_READ_ONLY`:
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Error [SERVER_READ_ONLY]: Operation rejected. OpenProject MCP server is running in read-only mode."
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}
|
||||
```
|
||||
3. **Phase 1 Verification**: All 10 Phase 1 tools are read-only (`readOnly: true`), ensuring 100% availability in read-only mode while providing the enforcement harness for Phase 2 mutations.
|
||||
|
||||
### 2.4 Stdio Transport Integrity & Stderr Logging
|
||||
In Model Context Protocol stdio transport:
|
||||
- `stdout` is the communication channel for JSON-RPC messages. Any non-JSON text emitted to `stdout` will corrupt the transport and crash the MCP client.
|
||||
- All diagnostics, banners, startup messages, and errors must be directed to `stderr`.
|
||||
- `createServer` and `index.ts` ensure all startup logging uses `console.error`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Interfaces & Contracts
|
||||
|
||||
### 3.1 `src/server.ts`
|
||||
```typescript
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { AppConfig } from "./config";
|
||||
import { OpenProjectClient } from "./client/api-client";
|
||||
|
||||
export interface OpenProjectMcpServer {
|
||||
server: McpServer;
|
||||
client: OpenProjectClient;
|
||||
start: (transport?: Transport) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createServer(config: AppConfig): OpenProjectMcpServer;
|
||||
```
|
||||
|
||||
### 3.2 Tool Execution Wrapping in `src/tools/common.ts` & `src/tools/index.ts`
|
||||
Enhance `registerAllTools` and `registerTool` with `wrapExecute`:
|
||||
```typescript
|
||||
export interface RegisterToolsOptions {
|
||||
readOnly?: boolean;
|
||||
tools?: AnyToolDefinition[];
|
||||
wrapExecute?: (
|
||||
fn: () => Promise<McpToolResponse>,
|
||||
tool: AnyToolDefinition,
|
||||
args: Record<string, unknown>
|
||||
) => Promise<McpToolResponse>;
|
||||
}
|
||||
```
|
||||
|
||||
When `wrapExecute` is supplied:
|
||||
1. Checks if `isReadOnly && !tool.readOnly`. If so, returns `formatToolError(new OpenProjectError("SERVER_READ_ONLY", "Operation rejected. OpenProject MCP server is running in read-only mode."))`.
|
||||
2. Executes the tool inside `runWithContext({ client, isReadOnly }, fn)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing Strategy
|
||||
|
||||
1. **End-to-End Server Tests (`tests/mcp-server.test.ts`)**:
|
||||
- Verify server initialization with `createServer(config)`.
|
||||
- Connect client using `InMemoryTransport.createLinkedPair()`.
|
||||
- Verify `client.listTools()` returns all 10 Phase 1 tools.
|
||||
- Verify calling tools (`openproject_list_projects`, `openproject_get_work_package`) via `client.callTool()` executes successfully against live OpenProject 17 container.
|
||||
- Verify invalid arguments return `isError: true` without throwing unhandled exceptions.
|
||||
2. **Read-Only Mode Tests (`tests/read-only.test.ts`)**:
|
||||
- Verify config parsing with `OPENPROJECT_READ_ONLY=true` and `--read-only`.
|
||||
- Verify that all Phase 1 tools remain listed and functional.
|
||||
- Verify that a synthetic mutating tool (`readOnly: false`) is omitted from `client.listTools()`.
|
||||
- Verify that invoking a mutating tool triggers `SERVER_READ_ONLY` execution guard.
|
||||
3. **Subprocess Stdio Verification**:
|
||||
- Spawn `bun run src/index.ts` as a child process and verify startup banner on `stderr`.
|
||||
Reference in New Issue
Block a user