fix: add missing runtime stage name to Dockerfile (#2765)
* 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>
This commit is contained in:
parent
e296d8b9d9
commit
1c2d7ab307
12 changed files with 1743 additions and 4 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
202
packages/mcp-server/README.md
Normal file
202
packages/mcp-server/README.md
Normal file
|
|
@ -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
|
||||
36
packages/mcp-server/package.json
Normal file
36
packages/mcp-server/package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
68
packages/mcp-server/src/cli.ts
Normal file
68
packages/mcp-server/src/cli.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
14
packages/mcp-server/src/index.ts
Normal file
14
packages/mcp-server/src/index.ts
Normal file
|
|
@ -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';
|
||||
628
packages/mcp-server/src/mcp-server.test.ts
Normal file
628
packages/mcp-server/src/mcp-server.test.ts
Normal file
|
|
@ -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<string, unknown>) => void> = [];
|
||||
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> = [];
|
||||
|
||||
/** 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<string, unknown>) {
|
||||
this.cwd = (options?.cwd as string) ?? '';
|
||||
this.args = (options?.args as string[]) ?? [];
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.startError) throw this.startError;
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<string, unknown>) => 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<void> {
|
||||
this.prompted.push(message);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.aborted = true;
|
||||
}
|
||||
|
||||
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
|
||||
this.uiResponses.push({ requestId, response });
|
||||
}
|
||||
|
||||
/** Test helper — emit an event to all listeners */
|
||||
emitEvent(event: Record<string, unknown>): 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<string> {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>): 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<string, unknown>).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');
|
||||
});
|
||||
});
|
||||
278
packages/mcp-server/src/server.ts
Normal file
278
packages/mcp-server/src/server.ts
Normal file
|
|
@ -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<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 };
|
||||
}
|
||||
328
packages/mcp-server/src/session-manager.ts
Normal file
328
packages/mcp-server/src/session-manager.ts
Normal file
|
|
@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, ManagedSession>();
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
|
||||
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<string, unknown>)) {
|
||||
// Check if it's a blocked stop (not truly terminal — it's a blocker)
|
||||
if (isBlockedNotification(event as Record<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
session.status = 'blocked';
|
||||
session.pendingBlocker = extractBlocker(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeout(ms: number, message: string): Promise<never> {
|
||||
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<string, unknown>).title ?? (uiEvent as Record<string, unknown>).message ?? ''),
|
||||
event: uiEvent,
|
||||
};
|
||||
}
|
||||
107
packages/mcp-server/src/types.ts
Normal file
107
packages/mcp-server/src/types.ts
Normal file
|
|
@ -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;
|
||||
24
packages/mcp-server/tsconfig.json
Normal file
24
packages/mcp-server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
20
packages/rpc-client/package.json
Normal file
20
packages/rpc-client/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue