Skip to content

Commit c9212f9

Browse files
committed
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
1 parent 9691e66 commit c9212f9

5 files changed

Lines changed: 339 additions & 97 deletions

File tree

packages/domscribe-relay/src/cli/commands/mcp.command.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from 'commander';
22
import { RelayControl } from '../../lifecycle/relay-control.js';
3-
import { createMcpAdapter } from '../../mcp/mcp-adapter.js';
3+
import { createMcpAdapter, McpAdapter } from '../../mcp/mcp-adapter.js';
44
import { getWorkspaceRoot } from '../utils.js';
55

66
interface McpCommandOptions {
@@ -24,16 +24,39 @@ export const McpCommand = new Command('mcp')
2424
}
2525
});
2626

27+
function setupShutdownHandlers(adapter: McpAdapter): void {
28+
const shutdown = async (signal: string): Promise<void> => {
29+
console.error(`\n[domscribe-cli] Received ${signal}, shutting down MCP...`);
30+
await adapter.close();
31+
process.exit(0);
32+
};
33+
34+
process.on('SIGINT', () => shutdown('SIGINT'));
35+
process.on('SIGTERM', () => shutdown('SIGTERM'));
36+
}
37+
2738
async function mcp(options: McpCommandOptions) {
39+
const { debug } = options;
2840
const workspaceRoot = getWorkspaceRoot();
2941

3042
if (!workspaceRoot) {
31-
throw new Error(
32-
'No workspace root found. Ensure you are running this command inside a workspace where domscribe is installed.',
33-
);
43+
if (debug) {
44+
console.error(
45+
'[domscribe-cli] No workspace found, starting in dormant mode',
46+
);
47+
}
48+
49+
const adapter = createMcpAdapter({
50+
mode: 'dormant',
51+
cwd: process.cwd(),
52+
debug,
53+
});
54+
55+
await adapter.start();
56+
setupShutdownHandlers(adapter);
57+
return;
3458
}
3559

36-
const { debug } = options;
3760
const bodyLimit = options.bodyLimit
3861
? parseInt(options.bodyLimit, 10)
3962
: undefined;
@@ -47,22 +70,13 @@ async function mcp(options: McpCommandOptions) {
4770
`[domscribe-cli] Starting MCP adapter (relay at http://${relayHost}:${relayPort})`,
4871
);
4972

50-
// Create and start MCP adapter
5173
const adapter = createMcpAdapter({
74+
mode: 'active',
5275
relayHost,
5376
relayPort,
5477
debug,
5578
});
5679

5780
await adapter.start();
58-
59-
// Handle shutdown
60-
const shutdown = async (signal: string): Promise<void> => {
61-
console.error(`\n[domscribe-cli] Received ${signal}, shutting down MCP...`);
62-
await adapter.close();
63-
process.exit(0);
64-
};
65-
66-
process.on('SIGINT', () => shutdown('SIGINT'));
67-
process.on('SIGTERM', () => shutdown('SIGTERM'));
81+
setupShutdownHandlers(adapter);
6882
}

packages/domscribe-relay/src/mcp/mcp-adapter.spec.ts

Lines changed: 131 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -37,91 +37,159 @@ vi.mock('../client/relay-http-client.js', () => ({
3737
},
3838
}));
3939

40+
function getServer(adapter: McpAdapter) {
41+
return (
42+
adapter as unknown as {
43+
server: {
44+
registeredTools: Map<string, unknown>;
45+
registeredPrompts: Map<string, unknown>;
46+
};
47+
}
48+
).server;
49+
}
50+
4051
describe('McpAdapter', () => {
41-
it('should register all 12 tools', () => {
42-
// Act
43-
const adapter = new McpAdapter({
44-
relayHost: 'localhost',
45-
relayPort: 9876,
52+
describe('active mode', () => {
53+
it('should register all 12 tools', () => {
54+
// Act
55+
const adapter = new McpAdapter({
56+
mode: 'active',
57+
relayHost: 'localhost',
58+
relayPort: 9876,
59+
});
60+
61+
// Assert
62+
const server = getServer(adapter);
63+
expect(server.registeredTools.size).toBe(12);
64+
expect(server.registeredTools.has('domscribe.resolve')).toBe(true);
65+
expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true);
66+
expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true);
67+
expect(server.registeredTools.has('domscribe.manifest.query')).toBe(true);
68+
expect(server.registeredTools.has('domscribe.annotation.get')).toBe(true);
69+
expect(server.registeredTools.has('domscribe.annotation.list')).toBe(
70+
true,
71+
);
72+
expect(server.registeredTools.has('domscribe.annotation.process')).toBe(
73+
true,
74+
);
75+
expect(
76+
server.registeredTools.has('domscribe.annotation.updateStatus'),
77+
).toBe(true);
78+
expect(server.registeredTools.has('domscribe.annotation.respond')).toBe(
79+
true,
80+
);
81+
expect(server.registeredTools.has('domscribe.annotation.search')).toBe(
82+
true,
83+
);
84+
expect(server.registeredTools.has('domscribe.status')).toBe(true);
85+
expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true);
4686
});
4787

48-
// Assert — access internal server to verify registration
49-
const server = (
50-
adapter as unknown as {
51-
server: { registeredTools: Map<string, unknown> };
52-
}
53-
).server;
54-
expect(server.registeredTools.size).toBe(12);
55-
expect(server.registeredTools.has('domscribe.resolve')).toBe(true);
56-
expect(server.registeredTools.has('domscribe.resolve.batch')).toBe(true);
57-
expect(server.registeredTools.has('domscribe.manifest.stats')).toBe(true);
58-
expect(server.registeredTools.has('domscribe.manifest.query')).toBe(true);
59-
expect(server.registeredTools.has('domscribe.annotation.get')).toBe(true);
60-
expect(server.registeredTools.has('domscribe.annotation.list')).toBe(true);
61-
expect(server.registeredTools.has('domscribe.annotation.process')).toBe(
62-
true,
63-
);
64-
expect(
65-
server.registeredTools.has('domscribe.annotation.updateStatus'),
66-
).toBe(true);
67-
expect(server.registeredTools.has('domscribe.annotation.respond')).toBe(
68-
true,
69-
);
70-
expect(server.registeredTools.has('domscribe.annotation.search')).toBe(
71-
true,
72-
);
73-
expect(server.registeredTools.has('domscribe.status')).toBe(true);
74-
expect(server.registeredTools.has('domscribe.query.bySource')).toBe(true);
75-
});
88+
it('should register all 4 prompts', () => {
89+
// Act
90+
const adapter = new McpAdapter({
91+
mode: 'active',
92+
relayHost: 'localhost',
93+
relayPort: 9876,
94+
});
95+
96+
// Assert
97+
const server = getServer(adapter);
98+
expect(server.registeredPrompts.size).toBe(4);
99+
expect(server.registeredPrompts.has('process_next')).toBe(true);
100+
expect(server.registeredPrompts.has('check_status')).toBe(true);
101+
expect(server.registeredPrompts.has('explore_component')).toBe(true);
102+
expect(server.registeredPrompts.has('find_annotations')).toBe(true);
103+
});
76104

77-
it('should register all 4 prompts', () => {
78-
// Act
79-
const adapter = new McpAdapter({
80-
relayHost: 'localhost',
81-
relayPort: 9876,
105+
it('should start and connect transport', async () => {
106+
const adapter = new McpAdapter({
107+
mode: 'active',
108+
relayHost: 'localhost',
109+
relayPort: 9876,
110+
});
111+
112+
// Should not throw
113+
await adapter.start();
82114
});
83115

84-
// Assert
85-
const server = (
86-
adapter as unknown as {
87-
server: { registeredPrompts: Map<string, unknown> };
88-
}
89-
).server;
90-
expect(server.registeredPrompts.size).toBe(4);
91-
expect(server.registeredPrompts.has('process_next')).toBe(true);
92-
expect(server.registeredPrompts.has('check_status')).toBe(true);
93-
expect(server.registeredPrompts.has('explore_component')).toBe(true);
94-
expect(server.registeredPrompts.has('find_annotations')).toBe(true);
116+
it('should close gracefully', async () => {
117+
const adapter = new McpAdapter({
118+
mode: 'active',
119+
relayHost: 'localhost',
120+
relayPort: 9876,
121+
});
122+
123+
// Should not throw
124+
await adapter.close();
125+
});
95126
});
96127

97-
it('should start and connect transport', async () => {
98-
const adapter = new McpAdapter({
99-
relayHost: 'localhost',
100-
relayPort: 9876,
128+
describe('dormant mode', () => {
129+
it('should register only the status tool', () => {
130+
// Act
131+
const adapter = new McpAdapter({
132+
mode: 'dormant',
133+
cwd: '/home/user/some-project',
134+
});
135+
136+
// Assert
137+
const server = getServer(adapter);
138+
expect(server.registeredTools.size).toBe(1);
139+
expect(server.registeredTools.has('domscribe.status')).toBe(true);
101140
});
102141

103-
// Should not throw
104-
await adapter.start();
105-
});
142+
it('should register no prompts', () => {
143+
// Act
144+
const adapter = new McpAdapter({
145+
mode: 'dormant',
146+
cwd: '/home/user/some-project',
147+
});
106148

107-
it('should close gracefully', async () => {
108-
const adapter = new McpAdapter({
109-
relayHost: 'localhost',
110-
relayPort: 9876,
149+
// Assert
150+
const server = getServer(adapter);
151+
expect(server.registeredPrompts.size).toBe(0);
111152
});
112153

113-
// Should not throw
114-
await adapter.close();
154+
it('should start and connect transport', async () => {
155+
const adapter = new McpAdapter({
156+
mode: 'dormant',
157+
cwd: '/home/user/some-project',
158+
});
159+
160+
// Should not throw
161+
await adapter.start();
162+
});
163+
164+
it('should close gracefully', async () => {
165+
const adapter = new McpAdapter({
166+
mode: 'dormant',
167+
cwd: '/home/user/some-project',
168+
});
169+
170+
// Should not throw
171+
await adapter.close();
172+
});
115173
});
116174
});
117175

118176
describe('createMcpAdapter', () => {
119-
it('should return an McpAdapter instance', () => {
177+
it('should return an McpAdapter instance for active mode', () => {
120178
const adapter = createMcpAdapter({
179+
mode: 'active',
121180
relayHost: 'localhost',
122181
relayPort: 9876,
123182
});
124183

125184
expect(adapter).toBeInstanceOf(McpAdapter);
126185
});
186+
187+
it('should return an McpAdapter instance for dormant mode', () => {
188+
const adapter = createMcpAdapter({
189+
mode: 'dormant',
190+
cwd: '/tmp/test',
191+
});
192+
193+
expect(adapter).toBeInstanceOf(McpAdapter);
194+
});
127195
});

0 commit comments

Comments
 (0)