* feat: Registered 6 MCP tools (gsd_execute, gsd_status, gsd_result, gsd_… - "packages/mcp-server/src/server.ts" - "packages/mcp-server/src/cli.ts" - "packages/mcp-server/src/index.ts" - "packages/rpc-client/dist/index.d.ts" GSD-Task: S05/T02 * docs: Added 31 integration tests, build pipeline, and consumer README f… - "packages/mcp-server/src/mcp-server.test.ts" - "packages/mcp-server/README.md" - "packages/mcp-server/dist/" GSD-Task: S05/T03 * fix: add missing runtime stage name to Dockerfile CI pipeline uses `docker build --target runtime` but the FROM line lacked the `AS runtime` alias, causing the build to fail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
/**
|
|
* MCP Server — registers 6 GSD orchestration tools on McpServer.
|
|
*
|
|
* Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
|
|
* cannot resolve the SDK's subpath exports statically (same pattern as
|
|
* src/mcp-server.ts in the main package).
|
|
*/
|
|
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { join, resolve } from 'node:path';
|
|
import { z } from 'zod';
|
|
import type { SessionManager } from './session-manager.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MCP_PKG = '@modelcontextprotocol/sdk';
|
|
const SERVER_NAME = 'gsd';
|
|
const SERVER_VERSION = '2.51.0';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool result helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Wrap a JSON-serializable value as MCP tool content. */
|
|
function jsonContent(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
|
}
|
|
|
|
/** Return an MCP error response. */
|
|
function errorContent(message: string): { isError: true; content: Array<{ type: 'text'; text: string }> } {
|
|
return { isError: true, content: [{ type: 'text' as const, text: message }] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// gsd_query filesystem reader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function readProjectState(projectDir: string, _query: string): Promise<Record<string, unknown>> {
|
|
const gsdDir = join(resolve(projectDir), '.gsd');
|
|
const result: Record<string, unknown> = { projectDir: resolve(projectDir) };
|
|
|
|
// STATE.md — current execution state
|
|
try {
|
|
result.state = await readFile(join(gsdDir, 'STATE.md'), 'utf-8');
|
|
} catch {
|
|
result.state = null;
|
|
}
|
|
|
|
// PROJECT.md — project description
|
|
try {
|
|
result.project = await readFile(join(gsdDir, 'PROJECT.md'), 'utf-8');
|
|
} catch {
|
|
result.project = null;
|
|
}
|
|
|
|
// REQUIREMENTS.md — requirement contract
|
|
try {
|
|
result.requirements = await readFile(join(gsdDir, 'REQUIREMENTS.md'), 'utf-8');
|
|
} catch {
|
|
result.requirements = null;
|
|
}
|
|
|
|
// List milestones with basic metadata
|
|
const milestonesDir = join(gsdDir, 'milestones');
|
|
try {
|
|
const entries = await readdir(milestonesDir, { withFileTypes: true });
|
|
const milestones: Array<{ id: string; hasRoadmap: boolean; hasSummary: boolean }> = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const mDir = join(milestonesDir, entry.name);
|
|
const hasRoadmap = await fileExists(join(mDir, `${entry.name}-ROADMAP.md`));
|
|
const hasSummary = await fileExists(join(mDir, `${entry.name}-SUMMARY.md`));
|
|
milestones.push({ id: entry.name, hasRoadmap, hasSummary });
|
|
}
|
|
result.milestones = milestones;
|
|
} catch {
|
|
result.milestones = [];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function fileExists(path: string): Promise<boolean> {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP Server type — minimal interface for the dynamically-imported McpServer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface McpServerInstance {
|
|
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
|
|
connect(transport: unknown): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createMcpServer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create and configure an MCP server with 6 GSD orchestration tools.
|
|
*
|
|
* Returns the McpServer instance — call `connect(transport)` to start serving.
|
|
* Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues.
|
|
*/
|
|
export async function createMcpServer(sessionManager: SessionManager): Promise<{
|
|
server: McpServerInstance;
|
|
}> {
|
|
// Dynamic import — same workaround as src/mcp-server.ts
|
|
const mcpMod = await import(`${MCP_PKG}/server/mcp.js`);
|
|
const McpServer = mcpMod.McpServer;
|
|
|
|
const server: McpServerInstance = new McpServer(
|
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
{ capabilities: { tools: {} } },
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_execute — start a new GSD auto-mode session
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_execute',
|
|
'Start a GSD auto-mode session for a project directory. Returns a sessionId for tracking.',
|
|
{
|
|
projectDir: z.string().describe('Absolute path to the project directory'),
|
|
command: z.string().optional().describe('Command to send (default: "/gsd auto")'),
|
|
model: z.string().optional().describe('Model ID override'),
|
|
bare: z.boolean().optional().describe('Run in bare mode (skip user config)'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { projectDir, command, model, bare } = args as {
|
|
projectDir: string; command?: string; model?: string; bare?: boolean;
|
|
};
|
|
try {
|
|
const sessionId = await sessionManager.startSession(projectDir, { command, model, bare });
|
|
return jsonContent({ sessionId, status: 'started' });
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_status — poll session status
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_status',
|
|
'Get the current status of a GSD session including progress, recent events, and pending blockers.',
|
|
{
|
|
sessionId: z.string().describe('Session ID returned from gsd_execute'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { sessionId } = args as { sessionId: string };
|
|
try {
|
|
const session = sessionManager.getSession(sessionId);
|
|
if (!session) return errorContent(`Session not found: ${sessionId}`);
|
|
|
|
const durationMs = Date.now() - session.startTime;
|
|
const toolCallCount = session.events.filter(
|
|
(e) => (e as Record<string, unknown>).type === 'tool_use' ||
|
|
(e as Record<string, unknown>).type === 'tool_execution_start'
|
|
).length;
|
|
|
|
return jsonContent({
|
|
status: session.status,
|
|
progress: {
|
|
eventCount: session.events.length,
|
|
toolCalls: toolCallCount,
|
|
},
|
|
recentEvents: session.events.slice(-10),
|
|
pendingBlocker: session.pendingBlocker
|
|
? {
|
|
id: session.pendingBlocker.id,
|
|
method: session.pendingBlocker.method,
|
|
message: session.pendingBlocker.message,
|
|
}
|
|
: null,
|
|
cost: session.cost,
|
|
durationMs,
|
|
});
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_result — get accumulated session result
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_result',
|
|
'Get the result of a GSD session. Returns partial results if the session is still running.',
|
|
{
|
|
sessionId: z.string().describe('Session ID returned from gsd_execute'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { sessionId } = args as { sessionId: string };
|
|
try {
|
|
const result = sessionManager.getResult(sessionId);
|
|
return jsonContent(result);
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_cancel — cancel a running session
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_cancel',
|
|
'Cancel a running GSD session. Aborts the current operation and stops the process.',
|
|
{
|
|
sessionId: z.string().describe('Session ID returned from gsd_execute'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { sessionId } = args as { sessionId: string };
|
|
try {
|
|
await sessionManager.cancelSession(sessionId);
|
|
return jsonContent({ cancelled: true });
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_query — read project state from filesystem (no session needed)
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_query',
|
|
'Query GSD project state from the filesystem. Returns STATE.md, PROJECT.md, requirements, and milestone listing. Does not require an active session.',
|
|
{
|
|
projectDir: z.string().describe('Absolute path to the project directory'),
|
|
query: z.string().describe('What to query (e.g. "status", "milestones", "requirements")'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { projectDir, query } = args as { projectDir: string; query: string };
|
|
try {
|
|
const state = await readProjectState(projectDir, query);
|
|
return jsonContent(state);
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// gsd_resolve_blocker — resolve a pending blocker
|
|
// -----------------------------------------------------------------------
|
|
server.tool(
|
|
'gsd_resolve_blocker',
|
|
'Resolve a pending blocker in a GSD session by sending a response to the UI request.',
|
|
{
|
|
sessionId: z.string().describe('Session ID returned from gsd_execute'),
|
|
response: z.string().describe('Response to send for the pending blocker'),
|
|
},
|
|
async (args: Record<string, unknown>) => {
|
|
const { sessionId, response } = args as { sessionId: string; response: string };
|
|
try {
|
|
await sessionManager.resolveBlocker(sessionId, response);
|
|
return jsonContent({ resolved: true });
|
|
} catch (err) {
|
|
return errorContent(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
);
|
|
|
|
return { server };
|
|
}
|