diff --git a/Dockerfile b/Dockerfile index 10b27e6f6..b69e4bc6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Image: ghcr.io/gsd-build/gsd-pi # Used by: end users via docker run # ────────────────────────────────────────────── -FROM node:24-slim +FROM node:24-slim AS runtime # Git is required for GSD's git operations RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/package-lock.json b/package-lock.json index 0922a36d8..79eb7b36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.49.0", + "version": "2.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.49.0", + "version": "2.51.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -1815,6 +1815,10 @@ "win32" ] }, + "node_modules/@gsd/mcp-server": { + "resolved": "packages/mcp-server", + "link": true + }, "node_modules/@gsd/native": { "resolved": "packages/native", "link": true @@ -1835,6 +1839,10 @@ "resolved": "packages/pi-tui", "link": true }, + "node_modules/@gsd/rpc-client": { + "resolved": "packages/rpc-client", + "link": true + }, "node_modules/@gsd/studio": { "resolved": "studio", "link": true @@ -9141,6 +9149,25 @@ } } }, + "packages/mcp-server": { + "name": "@gsd/mcp-server", + "version": "2.51.0", + "dependencies": { + "@gsd/rpc-client": "*", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^4.0.0" + }, + "bin": { + "gsd-mcp-server": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "packages/native": { "name": "@gsd/native", "version": "0.1.0", @@ -9191,7 +9218,7 @@ }, "packages/pi-coding-agent": { "name": "@gsd/pi-coding-agent", - "version": "2.49.0", + "version": "2.51.0", "dependencies": { "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", @@ -9233,6 +9260,13 @@ "koffi": "^2.9.0" } }, + "packages/rpc-client": { + "name": "@gsd/rpc-client", + "version": "2.51.0", + "engines": { + "node": ">=22.0.0" + } + }, "studio": { "name": "@gsd/studio", "version": "0.0.0", diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..821cf7002 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,202 @@ +# @gsd/mcp-server + +MCP server exposing GSD orchestration tools for Claude Code, Cursor, and other MCP-compatible clients. + +Start GSD auto-mode sessions, poll progress, resolve blockers, and retrieve results — all through the [Model Context Protocol](https://modelcontextprotocol.io/). + +## Installation + +```bash +npm install @gsd/mcp-server +``` + +Or with the monorepo workspace: + +```bash +# Already available as a workspace package +npx gsd-mcp-server +``` + +## Configuration + +### Claude Code + +Add to your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "gsd": { + "command": "npx", + "args": ["gsd-mcp-server"], + "env": { + "GSD_CLI_PATH": "/path/to/gsd" + } + } + } +} +``` + +Or if installed globally: + +```json +{ + "mcpServers": { + "gsd": { + "command": "gsd-mcp-server" + } + } +} +``` + +### Cursor + +Add to `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "gsd": { + "command": "npx", + "args": ["gsd-mcp-server"], + "env": { + "GSD_CLI_PATH": "/path/to/gsd" + } + } + } +} +``` + +## Tools + +### `gsd_execute` + +Start a GSD auto-mode session for a project directory. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `projectDir` | `string` | ✅ | Absolute path to the project directory | +| `command` | `string` | | Command to send (default: `"/gsd auto"`) | +| `model` | `string` | | Model ID override | +| `bare` | `boolean` | | Run in bare mode (skip user config) | + +**Returns:** `{ sessionId, status: "started" }` + +### `gsd_status` + +Poll the current status of a running GSD session. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` | + +**Returns:** + +```json +{ + "status": "running", + "progress": { "eventCount": 42, "toolCalls": 15 }, + "recentEvents": [ ... ], + "pendingBlocker": null, + "cost": { "totalCost": 0.12, "tokens": { "input": 5000, "output": 2000, "cacheRead": 1000, "cacheWrite": 500 } }, + "durationMs": 45000 +} +``` + +### `gsd_result` + +Get the accumulated result of a session. Works for both running (partial) and completed sessions. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` | + +**Returns:** + +```json +{ + "sessionId": "abc-123", + "projectDir": "/path/to/project", + "status": "completed", + "durationMs": 120000, + "cost": { ... }, + "recentEvents": [ ... ], + "pendingBlocker": null, + "error": null +} +``` + +### `gsd_cancel` + +Cancel a running session. Aborts the current operation and stops the agent process. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` | + +**Returns:** `{ cancelled: true }` + +### `gsd_query` + +Query GSD project state from the filesystem without an active session. Returns STATE.md, PROJECT.md, requirements, and milestone listing. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `projectDir` | `string` | ✅ | Absolute path to the project directory | +| `query` | `string` | ✅ | What to query (e.g. `"status"`, `"milestones"`) | + +**Returns:** + +```json +{ + "projectDir": "/path/to/project", + "state": "...", + "project": "...", + "requirements": "...", + "milestones": [ + { "id": "M001", "hasRoadmap": true, "hasSummary": false } + ] +} +``` + +### `gsd_resolve_blocker` + +Resolve a pending blocker in a session by sending a response to the blocked UI request. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` | +| `response` | `string` | ✅ | Response to send for the pending blocker | + +**Returns:** `{ resolved: true }` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `GSD_CLI_PATH` | Absolute path to the GSD CLI binary. If not set, the server resolves `gsd` via `which`. | + +## Architecture + +``` +┌─────────────────┐ stdio ┌──────────────────┐ +│ MCP Client │ ◄────────────► │ @gsd/mcp-server │ +│ (Claude Code, │ JSON-RPC │ │ +│ Cursor, etc.) │ │ SessionManager │ +└─────────────────┘ │ │ │ + │ ▼ │ + │ @gsd/rpc-client │ + │ │ │ + │ ▼ │ + │ GSD CLI (child │ + │ process via RPC)│ + └──────────────────┘ +``` + +- **@gsd/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations. +- **SessionManager** — Manages RpcClient lifecycle. One session per project directory. Tracks events in a ring buffer (last 50), detects blockers, accumulates cost. +- **@gsd/rpc-client** — Low-level RPC client that spawns and communicates with the GSD CLI process via JSON-RPC over stdio. + +## License + +MIT diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..b55b9904d --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@gsd/mcp-server", + "version": "2.51.0", + "description": "MCP server exposing GSD orchestration tools for Claude Code, Cursor, and other MCP clients", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "gsd-mcp-server": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/mcp-server.test.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@gsd/rpc-client": "*", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts new file mode 100644 index 000000000..b483ac2c2 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +/** + * @gsd/mcp-server CLI — stdio transport entry point. + * + * Connects the MCP server to stdin/stdout for use by Claude Code, + * Cursor, and other MCP-compatible clients. + */ + +import { SessionManager } from './session-manager.js'; +import { createMcpServer } from './server.js'; + +const MCP_PKG = '@modelcontextprotocol/sdk'; + +async function main(): Promise { + const sessionManager = new SessionManager(); + + // Create the configured MCP server with all 6 tools + const { server } = await createMcpServer(sessionManager); + + // Dynamic import for StdioServerTransport (same TS subpath workaround) + const { StdioServerTransport } = await import(`${MCP_PKG}/server/stdio.js`); + const transport = new StdioServerTransport(); + + // Cleanup handler — stop all sessions before exiting + let cleaningUp = false; + async function cleanup(): Promise { + if (cleaningUp) return; + cleaningUp = true; + process.stderr.write('[gsd-mcp-server] Shutting down...\n'); + try { + await sessionManager.cleanup(); + } catch { + // swallow cleanup errors + } + try { + await server.close(); + } catch { + // swallow close errors + } + process.exit(0); + } + + process.on('SIGTERM', () => void cleanup()); + process.on('SIGINT', () => void cleanup()); + + // Handle stdin end — MCP client disconnected + process.stdin.on('end', () => void cleanup()); + + // Connect and start serving + try { + await server.connect(transport); + process.stderr.write('[gsd-mcp-server] MCP server started on stdio\n'); + } catch (err) { + process.stderr.write( + `[gsd-mcp-server] Fatal: failed to start — ${err instanceof Error ? err.message : String(err)}\n` + ); + await sessionManager.cleanup(); + process.exit(1); + } +} + +main().catch((err) => { + process.stderr.write( + `[gsd-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 000000000..f65ef29ac --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,14 @@ +/** + * @gsd/mcp-server — MCP server for GSD orchestration. + */ + +export { SessionManager } from './session-manager.js'; +export { createMcpServer } from './server.js'; +export type { + SessionStatus, + ManagedSession, + ExecuteOptions, + PendingBlocker, + CostAccumulator, +} from './types.js'; +export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; diff --git a/packages/mcp-server/src/mcp-server.test.ts b/packages/mcp-server/src/mcp-server.test.ts new file mode 100644 index 000000000..7f71d4fb2 --- /dev/null +++ b/packages/mcp-server/src/mcp-server.test.ts @@ -0,0 +1,628 @@ +/** + * @gsd/mcp-server — Integration and unit tests. + * + * Strategy: We cannot mock @gsd/rpc-client at the module level without + * --experimental-test-module-mocks. Instead we test by: + * + * 1. Subclassing SessionManager to inject a mock client factory + * 2. Testing event handling, state transitions, and error paths + * 3. Testing tool registration via createMcpServer + * 4. Testing CLI path resolution via static method + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { EventEmitter } from 'node:events'; + +import { SessionManager } from './session-manager.js'; +import { createMcpServer } from './server.js'; +import { MAX_EVENTS } from './types.js'; +import type { ManagedSession, CostAccumulator, PendingBlocker } from './types.js'; + +// --------------------------------------------------------------------------- +// Mock RpcClient (duck-typed to match RpcClient interface) +// --------------------------------------------------------------------------- + +class MockRpcClient { + started = false; + stopped = false; + aborted = false; + prompted: string[] = []; + private eventListeners: Array<(event: Record) => void> = []; + uiResponses: Array<{ requestId: string; response: Record }> = []; + + /** Control — set to make start() reject */ + startError: Error | null = null; + /** Control — set to make init() reject */ + initError: Error | null = null; + /** Control — override sessionId from init */ + initSessionId = 'mock-session-001'; + + cwd: string; + args: string[]; + + constructor(options?: Record) { + this.cwd = (options?.cwd as string) ?? ''; + this.args = (options?.args as string[]) ?? []; + } + + async start(): Promise { + if (this.startError) throw this.startError; + this.started = true; + } + + async stop(): Promise { + this.stopped = true; + } + + async init(): Promise<{ sessionId: string; version: string }> { + if (this.initError) throw this.initError; + return { sessionId: this.initSessionId, version: '2.51.0' }; + } + + onEvent(listener: (event: Record) => void): () => void { + this.eventListeners.push(listener); + return () => { + const idx = this.eventListeners.indexOf(listener); + if (idx >= 0) this.eventListeners.splice(idx, 1); + }; + } + + async prompt(message: string): Promise { + this.prompted.push(message); + } + + async abort(): Promise { + this.aborted = true; + } + + sendUIResponse(requestId: string, response: Record): void { + this.uiResponses.push({ requestId, response }); + } + + /** Test helper — emit an event to all listeners */ + emitEvent(event: Record): void { + for (const listener of this.eventListeners) { + listener(event); + } + } +} + +// --------------------------------------------------------------------------- +// TestableSessionManager — injects mock clients without module mocking +// --------------------------------------------------------------------------- + +/** + * Subclass that overrides startSession to use MockRpcClient instead of the + * real RpcClient. We directly construct the session object, mirroring the + * parent's logic but with our mock. + */ +class TestableSessionManager extends SessionManager { + /** The last mock client created */ + lastClient: MockRpcClient | null = null; + /** All mock clients */ + allClients: MockRpcClient[] = []; + /** Counter for unique session IDs across multiple sessions */ + private sessionCounter = 0; + /** Control: set to make startSession fail during init */ + nextInitError: Error | null = null; + /** Control: set to make startSession fail during start */ + nextStartError: Error | null = null; + + override async startSession(projectDir: string, options: { cliPath?: string; command?: string; model?: string; bare?: boolean } = {}): Promise { + if (!projectDir || projectDir.trim() === '') { + throw new Error('projectDir is required and cannot be empty'); + } + + const resolvedDir = resolve(projectDir); + + // Check duplicate via getSessionByDir + const existing = this.getSessionByDir(resolvedDir); + if (existing) { + throw new Error( + `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})` + ); + } + + const client = new MockRpcClient({ cwd: resolvedDir, args: [] }); + if (this.nextStartError) { + client.startError = this.nextStartError; + this.nextStartError = null; + } + if (this.nextInitError) { + client.initError = this.nextInitError; + this.nextInitError = null; + } + + this.sessionCounter++; + client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, '0')}`; + this.lastClient = client; + this.allClients.push(client); + + // Create the session shell + const session: ManagedSession = { + sessionId: '', + projectDir: resolvedDir, + status: 'starting', + client: client as any, // duck-typed mock + events: [], + pendingBlocker: null, + cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + startTime: Date.now(), + }; + + // Insert into internal sessions map — access via protected method + this._putSession(resolvedDir, session); + + try { + await client.start(); + + const initResult = await client.init(); + session.sessionId = initResult.sessionId; + session.status = 'running'; + + // Wire event tracking using the same handleEvent logic as parent + session.unsubscribe = client.onEvent((event: Record) => { + this._handleEvent(session, event); + }); + + // Kick off auto-mode + const command = options.command ?? '/gsd auto'; + await client.prompt(command); + + return session.sessionId; + } catch (err) { + session.status = 'error'; + session.error = err instanceof Error ? err.message : String(err); + try { await client.stop(); } catch { /* swallow */ } + throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`); + } + } + + /** Expose internal session map insertion for testing */ + _putSession(key: string, session: ManagedSession): void { + // Access the private sessions map via any cast + (this as any).sessions.set(key, session); + } + + /** Expose handleEvent for testing */ + _handleEvent(session: ManagedSession, event: Record): void { + (this as any).handleEvent(session, event); + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +let allManagers: TestableSessionManager[] = []; + +function createManager(): TestableSessionManager { + const mgr = new TestableSessionManager(); + allManagers.push(mgr); + return mgr; +} + +// --------------------------------------------------------------------------- +// SessionManager unit tests +// --------------------------------------------------------------------------- + +describe('SessionManager', () => { + let sm: TestableSessionManager; + + beforeEach(() => { + sm = createManager(); + }); + + afterEach(async () => { + for (const mgr of allManagers) { + await mgr.cleanup(); + } + allManagers = []; + }); + + it('startSession creates session and returns sessionId', async () => { + const sessionId = await sm.startSession('/tmp/test-project', { cliPath: '/usr/bin/gsd' }); + assert.equal(sessionId, 'mock-session-001'); + + const session = sm.getSession(sessionId); + assert.ok(session); + assert.equal(session.status, 'running'); + assert.equal(session.projectDir, resolve('/tmp/test-project')); + }); + + it('startSession sends /gsd auto by default', async () => { + await sm.startSession('/tmp/test-prompt', { cliPath: '/usr/bin/gsd' }); + assert.ok(sm.lastClient); + assert.deepEqual(sm.lastClient.prompted, ['/gsd auto']); + }); + + it('startSession sends custom command when provided', async () => { + await sm.startSession('/tmp/test-cmd', { cliPath: '/usr/bin/gsd', command: '/gsd auto --resume' }); + assert.ok(sm.lastClient); + assert.deepEqual(sm.lastClient.prompted, ['/gsd auto --resume']); + }); + + it('startSession rejects duplicate projectDir', async () => { + await sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' }); + await assert.rejects( + () => sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' }), + (err: Error) => { + assert.ok(err.message.includes('Session already active')); + return true; + }, + ); + }); + + it('startSession rejects empty projectDir', async () => { + await assert.rejects( + () => sm.startSession('', { cliPath: '/usr/bin/gsd' }), + (err: Error) => { + assert.ok(err.message.includes('projectDir is required')); + return true; + }, + ); + }); + + it('startSession sets error status on start() failure', async () => { + sm.nextStartError = new Error('spawn failed'); + + await assert.rejects( + () => sm.startSession('/tmp/fail-start', { cliPath: '/usr/bin/gsd' }), + (err: Error) => { + assert.ok(err.message.includes('Failed to start session')); + assert.ok(err.message.includes('spawn failed')); + return true; + }, + ); + }); + + it('startSession sets error status on init() failure', async () => { + sm.nextInitError = new Error('handshake failed'); + + await assert.rejects( + () => sm.startSession('/tmp/fail-init', { cliPath: '/usr/bin/gsd' }), + (err: Error) => { + assert.ok(err.message.includes('Failed to start session')); + assert.ok(err.message.includes('handshake failed')); + return true; + }, + ); + }); + + it('getSession returns undefined for unknown sessionId', () => { + const result = sm.getSession('nonexistent-id'); + assert.equal(result, undefined); + }); + + it('getSessionByDir returns session for known dir', async () => { + await sm.startSession('/tmp/by-dir', { cliPath: '/usr/bin/gsd' }); + const session = sm.getSessionByDir('/tmp/by-dir'); + assert.ok(session); + assert.equal(session.sessionId, 'mock-session-001'); + }); + + it('resolveBlocker errors when no pending blocker', async () => { + const sessionId = await sm.startSession('/tmp/no-blocker', { cliPath: '/usr/bin/gsd' }); + await assert.rejects( + () => sm.resolveBlocker(sessionId, 'some response'), + (err: Error) => { + assert.ok(err.message.includes('No pending blocker')); + return true; + }, + ); + }); + + it('resolveBlocker errors for unknown session', async () => { + await assert.rejects( + () => sm.resolveBlocker('unknown-session', 'some response'), + (err: Error) => { + assert.ok(err.message.includes('Session not found')); + return true; + }, + ); + }); + + it('resolveBlocker clears pendingBlocker and sends UI response', async () => { + const sessionId = await sm.startSession('/tmp/blocker-resolve', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + // Simulate a blocking UI request event + client.emitEvent({ + type: 'extension_ui_request', + id: 'req-42', + method: 'select', + title: 'Pick an option', + }); + + const session = sm.getSession(sessionId)!; + assert.ok(session.pendingBlocker); + assert.equal(session.status, 'blocked'); + + // Resolve the blocker + await sm.resolveBlocker(sessionId, 'option-a'); + + assert.equal(session.pendingBlocker, null); + assert.equal(session.status, 'running'); + assert.equal(client.uiResponses.length, 1); + assert.equal(client.uiResponses[0].requestId, 'req-42'); + }); + + it('cancelSession calls abort + stop on client', async () => { + const sessionId = await sm.startSession('/tmp/cancel-test', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + await sm.cancelSession(sessionId); + + assert.ok(client.aborted); + assert.ok(client.stopped); + + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'cancelled'); + }); + + it('cancelSession errors for unknown session', async () => { + await assert.rejects( + () => sm.cancelSession('unknown'), + (err: Error) => { + assert.ok(err.message.includes('Session not found')); + return true; + }, + ); + }); + + it('cleanup stops all active sessions', async () => { + await sm.startSession('/tmp/cleanup-1', { cliPath: '/usr/bin/gsd' }); + await sm.startSession('/tmp/cleanup-2', { cliPath: '/usr/bin/gsd' }); + + assert.equal(sm.allClients.length, 2); + + await sm.cleanup(); + + for (const client of sm.allClients) { + assert.ok(client.stopped, 'Client should be stopped after cleanup'); + } + }); + + it('event ring buffer caps at MAX_EVENTS', async () => { + const sessionId = await sm.startSession('/tmp/ring-buffer', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + for (let i = 0; i < MAX_EVENTS + 20; i++) { + client.emitEvent({ type: 'tool_use', index: i }); + } + + const session = sm.getSession(sessionId)!; + assert.equal(session.events.length, MAX_EVENTS); + // Oldest events trimmed — first event index should be 20 + assert.equal((session.events[0] as Record).index, 20); + }); + + it('blocker detection: non-fire-and-forget extension_ui_request sets pendingBlocker', async () => { + const sessionId = await sm.startSession('/tmp/blocker-detect', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + // 'select' is not in FIRE_AND_FORGET_METHODS + client.emitEvent({ + type: 'extension_ui_request', + id: 'req-99', + method: 'select', + title: 'Choose wisely', + }); + + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'blocked'); + assert.ok(session.pendingBlocker); + assert.equal(session.pendingBlocker.id, 'req-99'); + assert.equal(session.pendingBlocker.method, 'select'); + }); + + it('fire-and-forget methods do not set pendingBlocker', async () => { + const sessionId = await sm.startSession('/tmp/fire-forget', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + // 'notify' is fire-and-forget — on its own (no terminal prefix) should not block + client.emitEvent({ + type: 'extension_ui_request', + id: 'req-100', + method: 'notify', + message: 'Just a notification', + }); + + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'running'); + assert.equal(session.pendingBlocker, null); + }); + + it('terminal detection: auto-mode stopped sets status to completed', async () => { + const sessionId = await sm.startSession('/tmp/terminal', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + client.emitEvent({ + type: 'extension_ui_request', + method: 'notify', + message: 'Auto-mode stopped — all tasks complete', + id: 'term-1', + }); + + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'completed'); + }); + + it('terminal detection with blocked: message sets status to blocked', async () => { + const sessionId = await sm.startSession('/tmp/terminal-blocked', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + client.emitEvent({ + type: 'extension_ui_request', + method: 'notify', + message: 'Auto-mode stopped — blocked: needs user input', + id: 'block-1', + }); + + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'blocked'); + assert.ok(session.pendingBlocker); + }); + + it('cost tracking: cumulative-max from cost_update events', async () => { + const sessionId = await sm.startSession('/tmp/cost-track', { cliPath: '/usr/bin/gsd' }); + const client = sm.lastClient!; + + client.emitEvent({ + type: 'cost_update', + cumulativeCost: 0.05, + tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 }, + }); + + client.emitEvent({ + type: 'cost_update', + cumulativeCost: 0.12, + tokens: { input: 2500, output: 800, cacheRead: 150, cacheWrite: 300 }, + }); + + const session = sm.getSession(sessionId)!; + assert.equal(session.cost.totalCost, 0.12); + assert.equal(session.cost.tokens.input, 2500); + assert.equal(session.cost.tokens.output, 800); + assert.equal(session.cost.tokens.cacheRead, 200); // First was higher + assert.equal(session.cost.tokens.cacheWrite, 300); // Second was higher + }); + + it('getResult returns HeadlessJsonResult-shaped object', async () => { + const sessionId = await sm.startSession('/tmp/result-shape', { cliPath: '/usr/bin/gsd' }); + const result = sm.getResult(sessionId); + + assert.equal(result.sessionId, sessionId); + assert.equal(result.projectDir, resolve('/tmp/result-shape')); + assert.equal(result.status, 'running'); + assert.equal(typeof result.durationMs, 'number'); + assert.ok(result.cost); + assert.ok(Array.isArray(result.recentEvents)); + assert.equal(result.pendingBlocker, null); + assert.equal(result.error, null); + }); + + it('getResult errors for unknown session', () => { + assert.throws( + () => sm.getResult('unknown'), + (err: Error) => { + assert.ok(err.message.includes('Session not found')); + return true; + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// CLI path resolution tests +// --------------------------------------------------------------------------- + +describe('SessionManager.resolveCLIPath', () => { + const originalGsdPath = process.env['GSD_CLI_PATH']; + const originalPath = process.env['PATH']; + + afterEach(() => { + if (originalGsdPath !== undefined) { + process.env['GSD_CLI_PATH'] = originalGsdPath; + } else { + delete process.env['GSD_CLI_PATH']; + } + if (originalPath !== undefined) { + process.env['PATH'] = originalPath; + } + }); + + it('GSD_CLI_PATH env var takes precedence', () => { + process.env['GSD_CLI_PATH'] = '/custom/path/to/gsd'; + const result = SessionManager.resolveCLIPath(); + assert.equal(result, resolve('/custom/path/to/gsd')); + }); + + it('throws when GSD_CLI_PATH not set and which fails', () => { + delete process.env['GSD_CLI_PATH']; + process.env['PATH'] = '/nonexistent'; + assert.throws( + () => SessionManager.resolveCLIPath(), + (err: Error) => { + assert.ok(err.message.includes('Cannot find GSD CLI')); + return true; + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tool registration tests (via createMcpServer) +// --------------------------------------------------------------------------- + +describe('createMcpServer tool registration', () => { + let sm: TestableSessionManager; + + beforeEach(() => { + sm = createManager(); + }); + + afterEach(async () => { + for (const mgr of allManagers) { + await mgr.cleanup(); + } + allManagers = []; + }); + + it('creates server successfully with all required methods', async () => { + const { server } = await createMcpServer(sm); + assert.ok(server); + assert.ok(typeof server.connect === 'function'); + assert.ok(typeof server.close === 'function'); + }); + + it('gsd_execute flow returns sessionId on success', async () => { + const sessionId = await sm.startSession('/tmp/tool-exec', { cliPath: '/usr/bin/gsd' }); + assert.equal(typeof sessionId, 'string'); + assert.ok(sessionId.length > 0); + }); + + it('gsd_status flow returns correct shape', async () => { + const sessionId = await sm.startSession('/tmp/tool-status', { cliPath: '/usr/bin/gsd' }); + const session = sm.getSession(sessionId)!; + + assert.equal(typeof session.status, 'string'); + assert.ok(Array.isArray(session.events)); + assert.ok(session.cost); + assert.equal(typeof session.startTime, 'number'); + }); + + it('gsd_resolve_blocker flow returns error when no blocker', async () => { + const sessionId = await sm.startSession('/tmp/tool-resolve', { cliPath: '/usr/bin/gsd' }); + await assert.rejects( + () => sm.resolveBlocker(sessionId, 'fix'), + (err: Error) => { + assert.ok(err.message.includes('No pending blocker')); + return true; + }, + ); + }); + + it('gsd_result flow returns HeadlessJsonResult shape', async () => { + const sessionId = await sm.startSession('/tmp/tool-result', { cliPath: '/usr/bin/gsd' }); + const result = sm.getResult(sessionId); + + assert.ok('sessionId' in result); + assert.ok('projectDir' in result); + assert.ok('status' in result); + assert.ok('durationMs' in result); + assert.ok('cost' in result); + assert.ok('recentEvents' in result); + assert.ok('pendingBlocker' in result); + assert.ok('error' in result); + }); + + it('gsd_cancel flow marks session as cancelled', async () => { + const sessionId = await sm.startSession('/tmp/tool-cancel', { cliPath: '/usr/bin/gsd' }); + await sm.cancelSession(sessionId); + const session = sm.getSession(sessionId)!; + assert.equal(session.status, 'cancelled'); + }); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 000000000..202b4731a --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,278 @@ +/** + * 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> { + const gsdDir = join(resolve(projectDir), '.gsd'); + const result: Record = { 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 { + 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, handler: (args: Record) => Promise): unknown; + connect(transport: unknown): Promise; + close(): Promise; +} + +// --------------------------------------------------------------------------- +// 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) => { + 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) => { + 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).type === 'tool_use' || + (e as Record).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) => { + 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) => { + 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) => { + 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) => { + 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 }; +} diff --git a/packages/mcp-server/src/session-manager.ts b/packages/mcp-server/src/session-manager.ts new file mode 100644 index 000000000..6c1ecf5db --- /dev/null +++ b/packages/mcp-server/src/session-manager.ts @@ -0,0 +1,328 @@ +/** + * SessionManager — manages RpcClient lifecycle for background GSD execution. + * + * One active session per projectDir. Tracks events in a ring buffer, + * detects blockers, tracks terminal state, and accumulates cost using + * the cumulative-max pattern (K004). + */ + +import { execSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { RpcClient } from '@gsd/rpc-client'; +import type { SdkAgentEvent, RpcInitResult, RpcCostUpdateEvent, RpcExtensionUIRequest } from '@gsd/rpc-client'; +import type { + ManagedSession, + ExecuteOptions, + PendingBlocker, + CostAccumulator, + SessionStatus, +} from './types.js'; +import { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; + +// --------------------------------------------------------------------------- +// Inlined detection logic (from headless-events.ts — no internal package imports) +// --------------------------------------------------------------------------- + +const FIRE_AND_FORGET_METHODS = new Set([ + 'notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text', +]); + +const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped']; + +function isTerminalNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false; + const message = String(event.message ?? '').toLowerCase(); + return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)); +} + +function isBlockedNotification(event: Record): boolean { + if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false; + const message = String(event.message ?? '').toLowerCase(); + return message.includes('blocked:'); +} + +function isBlockingUIRequest(event: Record): boolean { + if (event.type !== 'extension_ui_request') return false; + const method = String(event.method ?? ''); + return !FIRE_AND_FORGET_METHODS.has(method); +} + +// --------------------------------------------------------------------------- +// SessionManager +// --------------------------------------------------------------------------- + +export class SessionManager { + /** Sessions keyed by projectDir for duplicate-start prevention */ + private sessions = new Map(); + + /** + * Start a new GSD auto-mode session for the given project directory. + * + * Rejects if a session already exists for this projectDir. + * Creates an RpcClient, starts the process, performs the v2 init handshake, + * wires event tracking, and sends '/gsd auto' to begin execution. + */ + async startSession(projectDir: string, options: ExecuteOptions = {}): Promise { + if (!projectDir || projectDir.trim() === '') { + throw new Error('projectDir is required and cannot be empty'); + } + + const resolvedDir = resolve(projectDir); + + const existing = this.sessions.get(resolvedDir); + if (existing) { + throw new Error( + `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})` + ); + } + + const cliPath = options.cliPath ?? SessionManager.resolveCLIPath(); + + const args: string[] = ['--mode', 'rpc']; + if (options.model) args.push('--model', options.model); + if (options.bare) args.push('--bare'); + + const client = new RpcClient({ + cliPath, + cwd: resolvedDir, + args, + }); + + // Build the session shell before async operations so we can track state + const session: ManagedSession = { + sessionId: '', // filled after init + projectDir: resolvedDir, + status: 'starting', + client, + events: [], + pendingBlocker: null, + cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + startTime: Date.now(), + }; + + // Insert into map early (keyed by dir) so concurrent starts are rejected + this.sessions.set(resolvedDir, session); + + try { + // Start the process with timeout + await Promise.race([ + client.start(), + timeout(INIT_TIMEOUT_MS, `RpcClient.start() timed out after ${INIT_TIMEOUT_MS}ms`), + ]); + + // Perform v2 init handshake + const initResult: RpcInitResult = await Promise.race([ + client.init(), + timeout(INIT_TIMEOUT_MS, `RpcClient.init() timed out after ${INIT_TIMEOUT_MS}ms`), + ]) as RpcInitResult; + + session.sessionId = initResult.sessionId; + session.status = 'running'; + + // Wire event tracking + session.unsubscribe = client.onEvent((event: SdkAgentEvent) => { + this.handleEvent(session, event); + }); + + // Kick off auto-mode + const command = options.command ?? '/gsd auto'; + await client.prompt(command); + + return session.sessionId; + } catch (err) { + session.status = 'error'; + session.error = err instanceof Error ? err.message : String(err); + + // Attempt cleanup + try { await client.stop(); } catch { /* swallow cleanup errors */ } + + // Keep session in map so callers can inspect the error + throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`); + } + } + + /** + * Look up a session by sessionId. + * Linear scan is fine — we expect <10 concurrent sessions. + */ + getSession(sessionId: string): ManagedSession | undefined { + for (const session of this.sessions.values()) { + if (session.sessionId === sessionId) return session; + } + return undefined; + } + + /** + * Look up a session by project directory (direct map lookup). + */ + getSessionByDir(projectDir: string): ManagedSession | undefined { + return this.sessions.get(resolve(projectDir)); + } + + /** + * Resolve a pending blocker by sending a UI response. + */ + async resolveBlocker(sessionId: string, response: string): Promise { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + if (!session.pendingBlocker) throw new Error(`No pending blocker for session ${sessionId}`); + + const blocker = session.pendingBlocker; + session.client.sendUIResponse(blocker.id, { value: response }); + session.pendingBlocker = null; + if (session.status === 'blocked') { + session.status = 'running'; + } + } + + /** + * Cancel a running session — abort current operation then stop the process. + */ + async cancelSession(sessionId: string): Promise { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + try { + await session.client.abort(); + } catch { /* may already be stopped */ } + + try { + await session.client.stop(); + } catch { /* swallow */ } + + session.status = 'cancelled'; + session.unsubscribe?.(); + } + + /** + * Build a HeadlessJsonResult-shaped object from accumulated session state. + */ + getResult(sessionId: string): Record { + const session = this.getSession(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + + const durationMs = Date.now() - session.startTime; + + return { + sessionId: session.sessionId, + projectDir: session.projectDir, + status: session.status, + durationMs, + cost: session.cost, + recentEvents: session.events.slice(-10), + pendingBlocker: session.pendingBlocker + ? { id: session.pendingBlocker.id, method: session.pendingBlocker.method, message: session.pendingBlocker.message } + : null, + error: session.error ?? null, + }; + } + + /** + * Stop all active sessions and clean up resources. + */ + async cleanup(): Promise { + const stopPromises: Promise[] = []; + + for (const session of this.sessions.values()) { + session.unsubscribe?.(); + if (session.status === 'running' || session.status === 'starting' || session.status === 'blocked') { + stopPromises.push( + session.client.stop().catch(() => { /* swallow */ }) + ); + session.status = 'cancelled'; + } + } + + await Promise.allSettled(stopPromises); + } + + /** + * Resolve the GSD CLI path. + * + * 1. GSD_CLI_PATH env var (highest priority) + * 2. `which gsd` → resolve to the actual dist/cli.js + */ + static resolveCLIPath(): string { + // Check env var first + const envPath = process.env['GSD_CLI_PATH']; + if (envPath) return resolve(envPath); + + // Fallback: locate `gsd` via which + try { + const gsdBin = execSync('which gsd', { encoding: 'utf-8' }).trim(); + if (gsdBin) { + // gsd bin is typically a symlink to dist/loader.js — return the resolved path + return resolve(gsdBin); + } + } catch { + // which failed + } + + throw new Error( + 'Cannot find GSD CLI. Set GSD_CLI_PATH environment variable or ensure `gsd` is in PATH.' + ); + } + + // --------------------------------------------------------------------------- + // Private: Event Handling + // --------------------------------------------------------------------------- + + private handleEvent(session: ManagedSession, event: SdkAgentEvent): void { + // Ring buffer: push and trim + session.events.push(event); + if (session.events.length > MAX_EVENTS) { + session.events.splice(0, session.events.length - MAX_EVENTS); + } + + // Cost tracking (K004 — cumulative-max) + if (event.type === 'cost_update') { + const costEvent = event as unknown as RpcCostUpdateEvent; + session.cost.totalCost = Math.max(session.cost.totalCost, costEvent.cumulativeCost ?? 0); + if (costEvent.tokens) { + session.cost.tokens.input = Math.max(session.cost.tokens.input, costEvent.tokens.input ?? 0); + session.cost.tokens.output = Math.max(session.cost.tokens.output, costEvent.tokens.output ?? 0); + session.cost.tokens.cacheRead = Math.max(session.cost.tokens.cacheRead, costEvent.tokens.cacheRead ?? 0); + session.cost.tokens.cacheWrite = Math.max(session.cost.tokens.cacheWrite, costEvent.tokens.cacheWrite ?? 0); + } + } + + // Terminal detection — auto-mode/step-mode stopped + if (isTerminalNotification(event as Record)) { + // Check if it's a blocked stop (not truly terminal — it's a blocker) + if (isBlockedNotification(event as Record)) { + session.status = 'blocked'; + session.pendingBlocker = extractBlocker(event); + } else { + session.status = 'completed'; + session.unsubscribe?.(); + } + return; + } + + // Blocker detection — non-fire-and-forget extension_ui_request + if (isBlockingUIRequest(event as Record)) { + session.status = 'blocked'; + session.pendingBlocker = extractBlocker(event); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function timeout(ms: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); +} + +function extractBlocker(event: SdkAgentEvent): PendingBlocker { + const uiEvent = event as unknown as RpcExtensionUIRequest; + return { + id: String(uiEvent.id ?? ''), + method: String(uiEvent.method ?? ''), + message: String((uiEvent as Record).title ?? (uiEvent as Record).message ?? ''), + event: uiEvent, + }; +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts new file mode 100644 index 000000000..43cf3671e --- /dev/null +++ b/packages/mcp-server/src/types.ts @@ -0,0 +1,107 @@ +/** + * MCP Server types — session lifecycle and orchestration. + */ + +import type { RpcClient, SdkAgentEvent, RpcCostUpdateEvent, RpcExtensionUIRequest } from '@gsd/rpc-client'; + +// --------------------------------------------------------------------------- +// Session Status +// --------------------------------------------------------------------------- + +export type SessionStatus = 'starting' | 'running' | 'blocked' | 'completed' | 'error' | 'cancelled'; + +// --------------------------------------------------------------------------- +// Managed Session +// --------------------------------------------------------------------------- + +export interface ManagedSession { + /** Unique session ID returned from RpcClient.init() */ + sessionId: string; + + /** Absolute path to the project directory */ + projectDir: string; + + /** Current lifecycle status */ + status: SessionStatus; + + /** The RpcClient instance managing the agent process */ + client: RpcClient; + + /** Ring buffer of recent events (capped at MAX_EVENTS) */ + events: SdkAgentEvent[]; + + /** Pending blocker requiring user response, if any */ + pendingBlocker: PendingBlocker | null; + + /** Cumulative cost tracking (max pattern per K004) */ + cost: CostAccumulator; + + /** Session start timestamp */ + startTime: number; + + /** Error message if status is 'error' */ + error?: string; + + /** Cleanup function to unsubscribe from events */ + unsubscribe?: () => void; +} + +// --------------------------------------------------------------------------- +// Pending Blocker +// --------------------------------------------------------------------------- + +export interface PendingBlocker { + /** The extension_ui_request id */ + id: string; + + /** The request method (e.g. 'select', 'confirm', 'input') */ + method: string; + + /** Human-readable message or title */ + message: string; + + /** Full event payload for inspection */ + event: RpcExtensionUIRequest; +} + +// --------------------------------------------------------------------------- +// Cost Accumulator (K004 — cumulative-max) +// --------------------------------------------------------------------------- + +export interface CostAccumulator { + totalCost: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +} + +// --------------------------------------------------------------------------- +// Execute Options +// --------------------------------------------------------------------------- + +export interface ExecuteOptions { + /** Command to send after '/gsd auto' (default: none) */ + command?: string; + + /** Model ID override */ + model?: string; + + /** Run in bare mode (skip user config) */ + bare?: boolean; + + /** Path to CLI binary (overrides GSD_CLI_PATH and which resolution) */ + cliPath?: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum number of events kept in the ring buffer */ +export const MAX_EVENTS = 50; + +/** Timeout for RpcClient initialization (ms) */ +export const INIT_TIMEOUT_MS = 30_000; diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 000000000..779b48aca --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "Node16", + "lib": ["ES2024"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "inlineSourceMap": false, + "moduleResolution": "Node16", + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/rpc-client/package.json b/packages/rpc-client/package.json new file mode 100644 index 000000000..50461c856 --- /dev/null +++ b/packages/rpc-client/package.json @@ -0,0 +1,20 @@ +{ + "name": "@gsd/rpc-client", + "version": "2.51.0", + "description": "Standalone RPC client SDK for GSD — zero internal dependencies", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=22.0.0" + } +}