From 1725b06beecd535d71b75dae4f4824f708afe58a Mon Sep 17 00:00:00 2001 From: Kaushik Gnanaskandan Date: Thu, 26 Mar 2026 10:21:04 -0700 Subject: [PATCH] feat(relay): add dormant mode for MCP server when no workspace is detected Instead of crashing with exit(1) when no .domscribe/ directory exists, the MCP server now starts in dormant mode with a single diagnostic status tool. This prevents "failed" MCP server noise in agent sessions opened outside domscribe workspaces. - Add DormantStatusTool returning workspace status and setup guidance - Introduce discriminated union on McpAdapterOptions (active | dormant) - McpAdapter branches on mode: full tools+prompts vs diagnostic-only - Extract setupShutdownHandlers in mcp.command.ts to avoid duplication --- .../src/cli/commands/mcp.command.ts | 46 +++-- .../src/mcp/mcp-adapter.spec.ts | 194 ++++++++++++------ .../domscribe-relay/src/mcp/mcp-adapter.ts | 75 +++++-- .../src/mcp/tools/dormant-status.tool.spec.ts | 57 +++++ .../src/mcp/tools/dormant-status.tool.ts | 64 ++++++ 5 files changed, 339 insertions(+), 97 deletions(-) create mode 100644 packages/domscribe-relay/src/mcp/tools/dormant-status.tool.spec.ts create mode 100644 packages/domscribe-relay/src/mcp/tools/dormant-status.tool.ts diff --git a/packages/domscribe-relay/src/cli/commands/mcp.command.ts b/packages/domscribe-relay/src/cli/commands/mcp.command.ts index d37ec08..561fa59 100644 --- a/packages/domscribe-relay/src/cli/commands/mcp.command.ts +++ b/packages/domscribe-relay/src/cli/commands/mcp.command.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { RelayControl } from '../../lifecycle/relay-control.js'; -import { createMcpAdapter } from '../../mcp/mcp-adapter.js'; +import { createMcpAdapter, McpAdapter } from '../../mcp/mcp-adapter.js'; import { getWorkspaceRoot } from '../utils.js'; interface McpCommandOptions { @@ -24,16 +24,39 @@ export const McpCommand = new Command('mcp') } }); +function setupShutdownHandlers(adapter: McpAdapter): void { + const shutdown = async (signal: string): Promise => { + console.error(`\n[domscribe-cli] Received ${signal}, shutting down MCP...`); + await adapter.close(); + process.exit(0); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + async function mcp(options: McpCommandOptions) { + const { debug } = options; const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { - throw new Error( - 'No workspace root found. Ensure you are running this command inside a workspace where domscribe is installed.', - ); + if (debug) { + console.error( + '[domscribe-cli] No workspace found, starting in dormant mode', + ); + } + + const adapter = createMcpAdapter({ + mode: 'dormant', + cwd: process.cwd(), + debug, + }); + + await adapter.start(); + setupShutdownHandlers(adapter); + return; } - const { debug } = options; const bodyLimit = options.bodyLimit ? parseInt(options.bodyLimit, 10) : undefined; @@ -47,22 +70,13 @@ async function mcp(options: McpCommandOptions) { `[domscribe-cli] Starting MCP adapter (relay at http://${relayHost}:${relayPort})`, ); - // Create and start MCP adapter const adapter = createMcpAdapter({ + mode: 'active', relayHost, relayPort, debug, }); await adapter.start(); - - // Handle shutdown - const shutdown = async (signal: string): Promise => { - console.error(`\n[domscribe-cli] Received ${signal}, shutting down MCP...`); - await adapter.close(); - process.exit(0); - }; - - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); + setupShutdownHandlers(adapter); } diff --git a/packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts b/packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts index 55a66c4..8eec9f0 100644 --- a/packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts +++ b/packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts @@ -37,91 +37,159 @@ vi.mock('../client/relay-http-client.js', () => ({ }, })); +function getServer(adapter: McpAdapter) { + return ( + adapter as unknown as { + server: { + registeredTools: Map; + registeredPrompts: Map; + }; + } + ).server; +} + describe('McpAdapter', () => { - it('should register all 12 tools', () => { - // Act - const adapter = new McpAdapter({ - relayHost: 'localhost', - relayPort: 9876, + describe('active mode', () => { + it('should register all 12 tools', () => { + // Act + const adapter = new McpAdapter({ + mode: 'active', + relayHost: 'localhost', + relayPort: 9876, + }); + + // Assert + const server = getServer(adapter); + expect(server.registeredTools.size).toBe(12); + expect(server.registeredTools.has('domscribe.resolve')).toBe(true); + expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true); + expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true); + expect(server.registeredTools.has('domscribe.manifest.query')).toBe(true); + expect(server.registeredTools.has('domscribe.annotation.get')).toBe(true); + expect(server.registeredTools.has('domscribe.annotation.list')).toBe( + true, + ); + expect(server.registeredTools.has('domscribe.annotation.process')).toBe( + true, + ); + expect( + server.registeredTools.has('domscribe.annotation.updateStatus'), + ).toBe(true); + expect(server.registeredTools.has('domscribe.annotation.respond')).toBe( + true, + ); + expect(server.registeredTools.has('domscribe.annotation.search')).toBe( + true, + ); + expect(server.registeredTools.has('domscribe.status')).toBe(true); + expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true); }); - // Assert — access internal server to verify registration - const server = ( - adapter as unknown as { - server: { registeredTools: Map }; - } - ).server; - expect(server.registeredTools.size).toBe(12); - expect(server.registeredTools.has('domscribe.resolve')).toBe(true); - expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true); - expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true); - expect(server.registeredTools.has('domscribe.manifest.query')).toBe(true); - expect(server.registeredTools.has('domscribe.annotation.get')).toBe(true); - expect(server.registeredTools.has('domscribe.annotation.list')).toBe(true); - expect(server.registeredTools.has('domscribe.annotation.process')).toBe( - true, - ); - expect( - server.registeredTools.has('domscribe.annotation.updateStatus'), - ).toBe(true); - expect(server.registeredTools.has('domscribe.annotation.respond')).toBe( - true, - ); - expect(server.registeredTools.has('domscribe.annotation.search')).toBe( - true, - ); - expect(server.registeredTools.has('domscribe.status')).toBe(true); - expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true); - }); + it('should register all 4 prompts', () => { + // Act + const adapter = new McpAdapter({ + mode: 'active', + relayHost: 'localhost', + relayPort: 9876, + }); + + // Assert + const server = getServer(adapter); + expect(server.registeredPrompts.size).toBe(4); + expect(server.registeredPrompts.has('process_next')).toBe(true); + expect(server.registeredPrompts.has('check_status')).toBe(true); + expect(server.registeredPrompts.has('explore_component')).toBe(true); + expect(server.registeredPrompts.has('find_annotations')).toBe(true); + }); - it('should register all 4 prompts', () => { - // Act - const adapter = new McpAdapter({ - relayHost: 'localhost', - relayPort: 9876, + it('should start and connect transport', async () => { + const adapter = new McpAdapter({ + mode: 'active', + relayHost: 'localhost', + relayPort: 9876, + }); + + // Should not throw + await adapter.start(); }); - // Assert - const server = ( - adapter as unknown as { - server: { registeredPrompts: Map }; - } - ).server; - expect(server.registeredPrompts.size).toBe(4); - expect(server.registeredPrompts.has('process_next')).toBe(true); - expect(server.registeredPrompts.has('check_status')).toBe(true); - expect(server.registeredPrompts.has('explore_component')).toBe(true); - expect(server.registeredPrompts.has('find_annotations')).toBe(true); + it('should close gracefully', async () => { + const adapter = new McpAdapter({ + mode: 'active', + relayHost: 'localhost', + relayPort: 9876, + }); + + // Should not throw + await adapter.close(); + }); }); - it('should start and connect transport', async () => { - const adapter = new McpAdapter({ - relayHost: 'localhost', - relayPort: 9876, + describe('dormant mode', () => { + it('should register only the status tool', () => { + // Act + const adapter = new McpAdapter({ + mode: 'dormant', + cwd: '/home/user/some-project', + }); + + // Assert + const server = getServer(adapter); + expect(server.registeredTools.size).toBe(1); + expect(server.registeredTools.has('domscribe.status')).toBe(true); }); - // Should not throw - await adapter.start(); - }); + it('should register no prompts', () => { + // Act + const adapter = new McpAdapter({ + mode: 'dormant', + cwd: '/home/user/some-project', + }); - it('should close gracefully', async () => { - const adapter = new McpAdapter({ - relayHost: 'localhost', - relayPort: 9876, + // Assert + const server = getServer(adapter); + expect(server.registeredPrompts.size).toBe(0); }); - // Should not throw - await adapter.close(); + it('should start and connect transport', async () => { + const adapter = new McpAdapter({ + mode: 'dormant', + cwd: '/home/user/some-project', + }); + + // Should not throw + await adapter.start(); + }); + + it('should close gracefully', async () => { + const adapter = new McpAdapter({ + mode: 'dormant', + cwd: '/home/user/some-project', + }); + + // Should not throw + await adapter.close(); + }); }); }); describe('createMcpAdapter', () => { - it('should return an McpAdapter instance', () => { + it('should return an McpAdapter instance for active mode', () => { const adapter = createMcpAdapter({ + mode: 'active', relayHost: 'localhost', relayPort: 9876, }); expect(adapter).toBeInstanceOf(McpAdapter); }); + + it('should return an McpAdapter instance for dormant mode', () => { + const adapter = createMcpAdapter({ + mode: 'dormant', + cwd: '/tmp/test', + }); + + expect(adapter).toBeInstanceOf(McpAdapter); + }); }); diff --git a/packages/domscribe-relay/src/mcp/mcp-adapter.ts b/packages/domscribe-relay/src/mcp/mcp-adapter.ts index bb382d2..1f751d2 100644 --- a/packages/domscribe-relay/src/mcp/mcp-adapter.ts +++ b/packages/domscribe-relay/src/mcp/mcp-adapter.ts @@ -10,6 +10,7 @@ import { McpToolDefinition } from './tools/tool.defs.js'; import { McpPromptDefinition } from './prompts/prompt.defs.js'; import { RelayHttpClient } from '../client/relay-http-client.js'; import { RELAY_VERSION } from '../version.js'; +import { DormantStatusTool } from './tools/dormant-status.tool.js'; // Tool classes import { ResolveTool } from './tools/resolve.tool.js'; @@ -32,9 +33,10 @@ import { ExploreComponentPrompt } from './prompts/explore-component.prompt.js'; import { FindAnnotationsPrompt } from './prompts/find-annotations.prompt.js'; /** - * Options for creating an MCP adapter + * Options for active mode — relay is running, full tool set. */ -export interface McpAdapterOptions { +export interface McpAdapterActiveOptions { + mode: 'active'; /** Host where the relay server is running */ relayHost: string; /** Port where the relay server is running */ @@ -43,6 +45,25 @@ export interface McpAdapterOptions { debug?: boolean; } +/** + * Options for dormant mode — no workspace detected, diagnostic tool only. + */ +export interface McpAdapterDormantOptions { + mode: 'dormant'; + /** Working directory where the MCP server was started */ + cwd: string; + /** Enable debug logging */ + debug?: boolean; +} + +/** + * Options for creating an MCP adapter. + * Discriminated on `mode` to determine which tools are registered. + */ +export type McpAdapterOptions = + | McpAdapterActiveOptions + | McpAdapterDormantOptions; + /** * MCP adapter that registers tool and prompt handlers against the MCP server. * Tool logic lives in individual tool classes; the adapter is pure wiring. @@ -54,26 +75,27 @@ export class McpAdapter { constructor(options: McpAdapterOptions) { this.debug = options.debug ?? false; - const relayHttpClient = new RelayHttpClient( - options.relayHost, - options.relayPort, - ); + const capabilities: Record> = { tools: {} }; + + if (options.mode === 'active') { + capabilities['prompts'] = {}; + } this.server = new McpServer( - { - name: 'domscribe', - version: RELAY_VERSION, - }, - { - capabilities: { - tools: {}, - prompts: {}, - }, - }, + { name: 'domscribe', version: RELAY_VERSION }, + { capabilities }, ); - this.registerTools(relayHttpClient); - this.registerPrompts(); + if (options.mode === 'active') { + const relayHttpClient = new RelayHttpClient( + options.relayHost, + options.relayPort, + ); + this.registerTools(relayHttpClient); + this.registerPrompts(); + } else { + this.registerDormantTools(options.cwd); + } } private registerTools(relayHttpClient: RelayHttpClient): void { @@ -110,6 +132,23 @@ export class McpAdapter { } } + private registerDormantTools(cwd: string): void { + const tool = new DormantStatusTool(cwd); + this.server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.inputSchema, + }, + async (args) => { + if (this.debug) { + console.error(`[domscribe-mcp] Tool call: ${tool.name}`, args); + } + return tool.toolCallback(args); + }, + ); + } + private registerPrompts(): void { const prompts: McpPromptDefinition[] = [ new ProcessNextPrompt(), diff --git a/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.spec.ts b/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.spec.ts new file mode 100644 index 0000000..4e07de2 --- /dev/null +++ b/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.spec.ts @@ -0,0 +1,57 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { DormantStatusTool } from './dormant-status.tool.js'; +import { MCP_TOOLS } from './tool.defs.js'; + +describe('DormantStatusTool', () => { + describe('toolCallback', () => { + it('should return dormant status with correct cwd', async () => { + // Arrange + const cwd = '/home/user/some-project'; + const tool = new DormantStatusTool(cwd); + + // Act + const result: CallToolResult = await tool.toolCallback({}); + + // Assert + const structured = result.structuredContent as Record; + expect(structured['active']).toBe(false); + expect(structured['cwd']).toBe(cwd); + }); + + it('should return guidance with setup instructions', async () => { + // Arrange + const tool = new DormantStatusTool('/tmp/test'); + + // Act + const result: CallToolResult = await tool.toolCallback({}); + + // Assert + const structured = result.structuredContent as Record; + expect(structured['guidance']).toEqual(expect.any(String)); + expect(structured['guidance']).toContain('npx domscribe init'); + expect(structured['guidance']).toContain('.domscribe'); + }); + + it('should return text content matching structured content', async () => { + // Arrange + const tool = new DormantStatusTool('/tmp/test'); + + // Act + const result: CallToolResult = await tool.toolCallback({}); + + // Assert + expect(result.content).toHaveLength(1); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0]).toEqual({ + type: 'text', + text: JSON.stringify(result.structuredContent, null, 2), + }); + }); + }); + + it('should have correct tool metadata', () => { + const tool = new DormantStatusTool('/tmp/test'); + + expect(tool.name).toBe(MCP_TOOLS.STATUS); + }); +}); diff --git a/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.ts b/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.ts new file mode 100644 index 0000000..eb5a7ff --- /dev/null +++ b/packages/domscribe-relay/src/mcp/tools/dormant-status.tool.ts @@ -0,0 +1,64 @@ +/** + * MCP status tool for dormant mode — when no workspace is detected. + * Returns diagnostic info without requiring a relay connection. + * @module @domscribe/relay/mcp/tools/dormant-status-tool + */ +import { z } from 'zod'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + McpToolDefinition, + McpToolOutputSchema, + MCP_TOOLS, +} from './tool.defs.js'; + +const DormantStatusToolInputSchema = z.object({}); + +type DormantStatusToolInput = z.infer; + +const DormantStatusToolOutputSchema = McpToolOutputSchema.extend({ + active: z + .literal(false) + .describe('Whether Domscribe is active in this workspace'), + cwd: z + .string() + .describe('The working directory where the MCP server was started'), + guidance: z.string().describe('Instructions for setting up Domscribe'), +}); + +type DormantStatusToolOutput = z.infer; + +export class DormantStatusTool implements McpToolDefinition< + typeof DormantStatusToolInputSchema, + typeof DormantStatusToolOutputSchema +> { + name = MCP_TOOLS.STATUS; + description = + 'Get Domscribe workspace status. ' + + 'Domscribe is not active in this workspace — call this tool to find out why and how to set it up.'; + inputSchema = DormantStatusToolInputSchema; + outputSchema = DormantStatusToolOutputSchema; + + constructor(private readonly cwd: string) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async toolCallback(_input: DormantStatusToolInput): Promise { + const output: DormantStatusToolOutput = { + active: false, + cwd: this.cwd, + guidance: + 'Domscribe is not active in this workspace. ' + + 'No .domscribe/ directory was found at or above the current working directory. ' + + 'To set up Domscribe, run `npx domscribe init` in your project root.', + }; + + return { + structuredContent: output, + content: [ + { + type: 'text' as const, + text: JSON.stringify(output, null, 2), + }, + ], + }; + } +}