Skip to content

Commit 9cc31fd

Browse files
oratisclaude
andauthored
feat(mcp): elicitation — server-initiated structured input requests (#117)
Implements MCP elicitation (DEVELOPMENT_PLAN §3.3, form mode), the last browser-free MCP capability. A server can ask the user for structured input mid-tool-call via elicitation/create; DeepCode collects it and replies. core (mcp/client.ts): - connectMcpServer accepts ConnectMcpOpts.elicit. When provided, the client advertises the `elicitation` capability and registers an ElicitRequestSchema handler that routes `{ server, message, requestedSchema }` to the host callback, returning its { action: accept|decline|cancel, content? }. Omitting the handler leaves the capability undeclared (servers won't elicit). - connectAllMcpServers threads `elicit` through to every server. cli (repl.ts): - Provides an interactive elicit callback (via a const holder filled once readline exists): prints the server's message, prompts for each field in requestedSchema.properties, returns accept{content} or cancel. Headless stays non-interactive (no handler → no elicitation). Tests: +2 (a real spawned stdio server whose tool calls server.elicitInput → client routes to the host handler → accepted content flows back into the tool result; and: no handler → capability undeclared → the server's elicit errors and the tool surfaces isError rather than hanging). Core MCP suite 31 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da88b09 commit 9cc31fd

5 files changed

Lines changed: 165 additions & 4 deletions

File tree

apps/cli/src/repl.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getMcpPrompt,
2222
mcpPromptCommands,
2323
resolveMcpPromptInvocation,
24+
type McpElicitHandler,
2425
expandCommandBody,
2526
findCustomCommand,
2627
findStyle,
@@ -161,12 +162,19 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
161162
// M3c: connect MCP servers (best-effort; individual failures don't abort)
162163
let mcpServers: McpClientHandle[] = [];
163164
let mcpErrors: Array<{ serverName: string; error: string }> = [];
165+
// Elicitation handler holder — filled once the readline interface exists
166+
// (below). Until then (and there's nothing to elicit at connect time) it
167+
// cancels. Servers see the `elicitation` capability via this passthrough.
168+
const elicitHolder: { fn?: McpElicitHandler } = {};
169+
const elicitForServers: McpElicitHandler = (req) =>
170+
elicitHolder.fn ? elicitHolder.fn(req) : Promise.resolve({ action: 'cancel' });
164171
if (settings.mcpServers && Object.keys(settings.mcpServers).length > 0) {
165172
const enabled = settings.enabledMcpjsonServers;
166173
const disabled = settings.disabledMcpjsonServers ?? [];
167174
const result = await connectAllMcpServers(settings.mcpServers, {
168175
enabledOnly: enabled,
169176
disabled,
177+
elicit: elicitForServers,
170178
});
171179
mcpServers = result.handles;
172180
mcpErrors = result.errors;
@@ -285,6 +293,23 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
285293

286294
const rl = createInterface({ input: opts.input, output, terminal: true });
287295

296+
// Now that readline exists, let MCP servers elicit structured input from the
297+
// user: print the server's message, prompt for each requested field.
298+
elicitHolder.fn = async (req) => {
299+
output.write(`\n ⊞ ${req.server} requests input: ${req.message}\n`);
300+
const props = (req.requestedSchema.properties ?? {}) as Record<
301+
string,
302+
{ description?: string }
303+
>;
304+
const content: Record<string, string> = {};
305+
for (const [key, spec] of Object.entries(props)) {
306+
const label = spec.description ? `${key} (${spec.description})` : key;
307+
const ans = (await rl.question(` ${label}: `)).trim();
308+
if (ans) content[key] = ans;
309+
}
310+
return Object.keys(content).length > 0 ? { action: 'accept', content } : { action: 'cancel' };
311+
};
312+
288313
let ctrlCCount = 0;
289314
rl.on('SIGINT', () => {
290315
ctrlCCount++;

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ export {
208208
type ResourceRef,
209209
type ExpandResourcesResult,
210210
type McpPromptCommand,
211+
type McpElicitRequest,
212+
type McpElicitResult,
213+
type McpElicitHandler,
214+
type ConnectMcpOpts,
211215
} from './mcp/index.js';
212216

213217
// Plugins (M5 — manifest + hash pin; M5.1 — subprocess runtime + RPC bridge;

packages/core/src/mcp/client.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,24 @@ async function writeFakeServer(
4949
arguments?: Array<{ name: string; required?: boolean }>;
5050
text: string;
5151
}>,
52+
/** When set, the named tool triggers a server→client elicitation/create. */
53+
elicit?: { toolName: string; message: string; requestedSchema: object },
5254
): Promise<string> {
5355
const serverPath = join(dir, `${name}.mjs`);
5456
const capList = ['tools: {}'];
5557
if (resources) capList.push('resources: {}');
5658
if (prompts) capList.push('prompts: {}');
5759
const caps = `{ ${capList.join(', ')} }`;
60+
const elicitBranch = elicit
61+
? `
62+
if (req.params.name === ${JSON.stringify(elicit.toolName)}) {
63+
const r = await server.elicitInput({
64+
message: ${JSON.stringify(elicit.message)},
65+
requestedSchema: ${JSON.stringify(elicit.requestedSchema)},
66+
});
67+
return { content: [{ type: 'text', text: 'elicited:' + JSON.stringify(r) }] };
68+
}`
69+
: '';
5870
const resourceBlock = resources
5971
? `
6072
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '${TYPES_INDEX}';
@@ -103,7 +115,7 @@ const server = new Server(
103115
const TOOLS = ${JSON.stringify(tools)};
104116
105117
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
106-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
118+
server.setRequestHandler(CallToolRequestSchema, async (req) => {${elicitBranch}
107119
const argsStr = JSON.stringify(req.params.arguments);
108120
return {
109121
content: [{ type: 'text', text: 'called: ' + req.params.name + ' args: ' + argsStr }],
@@ -326,6 +338,76 @@ describe('MCP client', () => {
326338
await handle.close();
327339
}
328340
}, 20_000);
341+
342+
it('routes a server elicitation/create request to the host elicit handler', async () => {
343+
const serverScript = await writeFakeServer(
344+
tmp,
345+
'forms',
346+
[
347+
{
348+
name: 'ask',
349+
description: 'asks for input',
350+
inputSchema: { type: 'object', properties: {} },
351+
},
352+
],
353+
undefined,
354+
undefined,
355+
{
356+
toolName: 'ask',
357+
message: 'What is your name?',
358+
requestedSchema: {
359+
type: 'object',
360+
properties: { name: { type: 'string' } },
361+
required: ['name'],
362+
},
363+
},
364+
);
365+
let seen: { server: string; message: string } | null = null;
366+
const handle = await connectMcpServer(
367+
'forms',
368+
{ command: 'node', args: [serverScript] },
369+
{
370+
elicit: async (req) => {
371+
seen = { server: req.server, message: req.message };
372+
return { action: 'accept', content: { name: 'Ada' } };
373+
},
374+
},
375+
);
376+
try {
377+
const ask = handle.tools.find((t) => t.name === 'mcp__forms__ask')!;
378+
const result = await ask.execute({}, { cwd: tmp });
379+
// The host handler was invoked with the server's prompt...
380+
expect(seen).toEqual({ server: 'forms', message: 'What is your name?' });
381+
// ...and the accepted content flowed back to the server's tool result.
382+
expect(result.content).toContain('elicited:');
383+
expect(result.content).toContain('"action":"accept"');
384+
expect(result.content).toContain('"name":"Ada"');
385+
} finally {
386+
await handle.close();
387+
}
388+
}, 20_000);
389+
390+
it('does not advertise elicitation when no handler is supplied', async () => {
391+
// A server that tries to elicit against a client without the capability
392+
// gets an error from its elicitInput call; the tool surfaces it (no hang).
393+
const serverScript = await writeFakeServer(
394+
tmp,
395+
'forms2',
396+
[{ name: 'ask', description: 'asks', inputSchema: { type: 'object', properties: {} } }],
397+
undefined,
398+
undefined,
399+
{ toolName: 'ask', message: 'name?', requestedSchema: { type: 'object', properties: {} } },
400+
);
401+
const handle = await connectMcpServer('forms2', { command: 'node', args: [serverScript] });
402+
try {
403+
const ask = handle.tools.find((t) => t.name === 'mcp__forms2__ask')!;
404+
const result = await ask.execute({}, { cwd: tmp });
405+
// elicitInput rejects (client lacks the capability) → tool reports an error.
406+
expect(result.isError).toBe(true);
407+
} finally {
408+
await handle.close();
409+
}
410+
}, 20_000);
329411
});
330412

331413
describe('pickTransportKind', () => {

packages/core/src/mcp/client.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
1313
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
1414
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
1515
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
16+
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
1617
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
1718
import type { McpServerConfig } from '../config/types.js';
1819
import type { ToolDefinition, ToolHandler, ToolResult } from '../types.js';
@@ -43,6 +44,35 @@ export interface McpPromptMeta {
4344
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
4445
}
4546

47+
/**
48+
* A server-initiated request for structured input (elicitation/create, form
49+
* mode). The host answers by collecting the fields described by `requestedSchema`.
50+
*/
51+
export interface McpElicitRequest {
52+
server: string;
53+
message: string;
54+
/** JSON Schema (object) describing the fields the server wants. */
55+
requestedSchema: Record<string, unknown>;
56+
}
57+
58+
export type McpElicitResult =
59+
| { action: 'accept'; content: Record<string, unknown> }
60+
| { action: 'decline' }
61+
| { action: 'cancel' };
62+
63+
/** Host callback that answers a server's elicitation request. */
64+
export type McpElicitHandler = (req: McpElicitRequest) => Promise<McpElicitResult>;
65+
66+
export interface ConnectMcpOpts {
67+
/**
68+
* Handler for server-initiated elicitation (structured input) requests. When
69+
* provided, the client advertises the `elicitation` capability and routes
70+
* `elicitation/create` requests here. Omit in non-interactive hosts so
71+
* servers know not to elicit.
72+
*/
73+
elicit?: McpElicitHandler;
74+
}
75+
4676
export interface McpClientHandle {
4777
serverName: string;
4878
client: Client;
@@ -145,6 +175,7 @@ async function buildTransport(
145175
export async function connectMcpServer(
146176
serverName: string,
147177
config: McpServerConfig,
178+
opts: ConnectMcpOpts = {},
148179
): Promise<McpClientHandle> {
149180
const kind = pickTransportKind(config);
150181
if (!kind) {
@@ -153,7 +184,22 @@ export async function connectMcpServer(
153184
);
154185
}
155186
const transport = await buildTransport(serverName, config, kind);
156-
const client = new Client({ name: 'deepcode', version: '0.1.0' }, { capabilities: {} });
187+
// Advertise elicitation support only when the host gave us a handler — an
188+
// empty `elicitation: {}` capability means form mode (SDK default).
189+
const capabilities = opts.elicit ? { elicitation: {} } : {};
190+
const client = new Client({ name: 'deepcode', version: '0.1.0' }, { capabilities });
191+
if (opts.elicit) {
192+
const elicit = opts.elicit;
193+
// Register before connect so an early server request can't race the handler.
194+
client.setRequestHandler(ElicitRequestSchema, async (req) => {
195+
const params = req.params as { message?: string; requestedSchema?: Record<string, unknown> };
196+
return elicit({
197+
server: serverName,
198+
message: params.message ?? '',
199+
requestedSchema: params.requestedSchema ?? { type: 'object', properties: {} },
200+
});
201+
});
202+
}
157203
await client.connect(transport);
158204

159205
// List the tools the server exposes
@@ -430,7 +476,7 @@ export interface ConnectAllResult {
430476

431477
export async function connectAllMcpServers(
432478
servers: Record<string, McpServerConfig>,
433-
opts: { enabledOnly?: string[]; disabled?: string[] } = {},
479+
opts: { enabledOnly?: string[]; disabled?: string[]; elicit?: McpElicitHandler } = {},
434480
): Promise<ConnectAllResult> {
435481
const handles: McpClientHandle[] = [];
436482
const errors: Array<{ serverName: string; error: string }> = [];
@@ -441,7 +487,7 @@ export async function connectAllMcpServers(
441487
if (enabled && !enabled.has(name)) continue;
442488
if (disabled.has(name)) continue;
443489
try {
444-
const handle = await connectMcpServer(name, cfg);
490+
const handle = await connectMcpServer(name, cfg, { elicit: opts.elicit });
445491
handles.push(handle);
446492
} catch (err) {
447493
errors.push({ serverName: name, error: (err as Error).message });

packages/core/src/mcp/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export {
2424
type ResourceRef,
2525
type ExpandResourcesResult,
2626
type McpPromptCommand,
27+
type McpElicitRequest,
28+
type McpElicitResult,
29+
type McpElicitHandler,
30+
type ConnectMcpOpts,
2731
} from './client.js';
2832

2933
export {

0 commit comments

Comments
 (0)