Skip to content

Commit da88b09

Browse files
oratisclaude
andauthored
feat(mcp): prompts as slash commands — /mcp__server__prompt (#116)
Completes MCP feature parity (DEVELOPMENT_PLAN §3.3): a server's prompts are listed on connect and invokable as `/mcp__<server>__<prompt> [args]` slash commands in the REPL. PR 2 of 2 (stacked on the resources PR). core (mcp/client.ts): - connectMcpServer lists prompts on connect (capability-gated, same graceful degradation as resources). McpClientHandle gains `prompts: McpPromptMeta[]`. - getMcpPrompt(handle, name, args) — fetches a prompt, flattens its messages to a single string. - mcpPromptCommands(handles) — surfaces prompts as `/mcp__server__prompt` command descriptors. - resolveMcpPromptInvocation(line, handles) — parses a REPL line: `key=value` tokens plus bare tokens mapped positionally onto the prompt's declared argument names; null for non-invocations / unknown server|prompt. cli (repl.ts): - `/mcp__server__prompt …` lines fetch the rendered prompt and submit it as the user message; startup banner lists resource + prompt counts and the available prompt commands. Tests: +6 (prompts listed on connect + getMcpPrompt round-trip with args forwarded, against a real spawned stdio server; resolveMcpPromptInvocation positional/key=value/mixed/no-args/unknown cases). Core suite 575 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6cb6287 commit da88b09

5 files changed

Lines changed: 261 additions & 1 deletion

File tree

apps/cli/src/repl.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
closeAllMcpServers,
1919
connectAllMcpServers,
2020
expandMcpResourceRefs,
21+
getMcpPrompt,
22+
mcpPromptCommands,
23+
resolveMcpPromptInvocation,
2124
expandCommandBody,
2225
findCustomCommand,
2326
findStyle,
@@ -190,11 +193,18 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
190193
const deferredNames = installToolSearch(tools, deferredMcpTools);
191194
if (mcpServers.length > 0) {
192195
const eager = mcpServers.reduce((n, h) => n + h.tools.length, 0) - deferredNames.length;
196+
const resourceCount = mcpServers.reduce((n, h) => n + h.resources.length, 0);
197+
const promptCmds = mcpPromptCommands(mcpServers);
193198
output.write(
194199
` ⊞ MCP: ${mcpServers.length} server(s) connected (${eager} tools` +
195200
(deferredNames.length > 0 ? `, ${deferredNames.length} deferred behind ToolSearch` : '') +
201+
(resourceCount > 0 ? `, ${resourceCount} resources` : '') +
202+
(promptCmds.length > 0 ? `, ${promptCmds.length} prompts` : '') +
196203
`)\n`,
197204
);
205+
if (promptCmds.length > 0) {
206+
output.write(` ⊞ MCP prompts: ${promptCmds.map((c) => c.command).join(', ')}\n`);
207+
}
198208
}
199209
if (mcpErrors.length > 0) {
200210
output.write(` ⊞ MCP: ${mcpErrors.length} server(s) failed (see /mcp)\n`);
@@ -320,6 +330,21 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
320330
continue;
321331
}
322332

333+
// MCP prompt command (`/mcp__<server>__<prompt> [args]`)? Fetch the rendered
334+
// prompt from the server and submit it as the user prompt.
335+
if (userInput.trim().startsWith('/mcp__') && mcpServers.length > 0) {
336+
const inv = resolveMcpPromptInvocation(userInput, mcpServers);
337+
if (inv) {
338+
try {
339+
userInput = await getMcpPrompt(inv.handle, inv.prompt, inv.args);
340+
output.write(` ▸ /mcp__${inv.handle.serverName}__${inv.prompt} (MCP prompt)\n\n`);
341+
} catch (err) {
342+
output.write(` ⚠ MCP prompt failed: ${(err as Error).message}\n`);
343+
continue;
344+
}
345+
}
346+
}
347+
323348
// Custom prompt-template command (.deepcode/commands/<name>.md)? Expand its
324349
// body with the args and submit it to the agent as the user prompt.
325350
if (userInput.trim().startsWith('/')) {

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,19 @@ export {
195195
readMcpResource,
196196
parseResourceRefs,
197197
expandMcpResourceRefs,
198+
getMcpPrompt,
199+
mcpPromptCommands,
200+
resolveMcpPromptInvocation,
198201
type McpClientHandle,
199202
type McpToolMeta,
200203
type McpResourceMeta,
204+
type McpPromptMeta,
201205
type ConnectAllResult,
202206
type BuildMcpServerOpts,
203207
type ServeMcpStdioOpts,
204208
type ResourceRef,
205209
type ExpandResourcesResult,
210+
type McpPromptCommand,
206211
} from './mcp/index.js';
207212

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

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

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import {
1414
connectAllMcpServers,
1515
connectMcpServer,
1616
expandMcpResourceRefs,
17+
getMcpPrompt,
18+
mcpPromptCommands,
1719
parseHelperOutput,
1820
parseResourceRefs,
1921
pickTransportKind,
2022
readMcpResource,
23+
resolveMcpPromptInvocation,
2124
} from './client.js';
2225

2326
const require_ = createRequire(import.meta.url);
@@ -40,9 +43,18 @@ async function writeFakeServer(
4043
name: string,
4144
tools: object[],
4245
resources?: Array<{ uri: string; name?: string; text: string; mimeType?: string }>,
46+
prompts?: Array<{
47+
name: string;
48+
description?: string;
49+
arguments?: Array<{ name: string; required?: boolean }>;
50+
text: string;
51+
}>,
4352
): Promise<string> {
4453
const serverPath = join(dir, `${name}.mjs`);
45-
const caps = resources ? '{ tools: {}, resources: {} }' : '{ tools: {} }';
54+
const capList = ['tools: {}'];
55+
if (resources) capList.push('resources: {}');
56+
if (prompts) capList.push('prompts: {}');
57+
const caps = `{ ${capList.join(', ')} }`;
4658
const resourceBlock = resources
4759
? `
4860
import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '${TYPES_INDEX}';
@@ -55,6 +67,25 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
5567
if (!found) throw new Error('no such resource: ' + req.params.uri);
5668
return { contents: [{ uri: found.uri, mimeType: found.mimeType ?? 'text/plain', text: found.text }] };
5769
});
70+
`
71+
: '';
72+
const promptBlock = prompts
73+
? `
74+
import { ListPromptsRequestSchema, GetPromptRequestSchema } from '${TYPES_INDEX}';
75+
const PROMPTS = ${JSON.stringify(prompts)};
76+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
77+
prompts: PROMPTS.map((p) => ({ name: p.name, description: p.description, arguments: p.arguments })),
78+
}));
79+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
80+
const found = PROMPTS.find((p) => p.name === req.params.name);
81+
if (!found) throw new Error('no such prompt: ' + req.params.name);
82+
const argsStr = JSON.stringify(req.params.arguments ?? {});
83+
return {
84+
messages: [
85+
{ role: 'user', content: { type: 'text', text: found.text + ' args=' + argsStr } },
86+
],
87+
};
88+
});
5889
`
5990
: '';
6091
await fs.writeFile(
@@ -79,6 +110,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
79110
};
80111
});
81112
${resourceBlock}
113+
${promptBlock}
82114
await server.connect(new StdioServerTransport());
83115
`,
84116
'utf8',
@@ -256,6 +288,44 @@ describe('MCP client', () => {
256288
await handle.close();
257289
}
258290
}, 20_000);
291+
292+
it('lists prompts on connect and fetches one with arguments', async () => {
293+
const serverScript = await writeFakeServer(
294+
tmp,
295+
'gh',
296+
[{ name: 'noop', description: 'd', inputSchema: { type: 'object', properties: {} } }],
297+
undefined,
298+
[
299+
{
300+
name: 'open_pr',
301+
description: 'Open a PR',
302+
arguments: [{ name: 'title', required: true }],
303+
text: 'Draft a PR titled',
304+
},
305+
],
306+
);
307+
const handle = await connectMcpServer('gh', { command: 'node', args: [serverScript] });
308+
try {
309+
expect(handle.prompts.map((p) => p.name)).toEqual(['open_pr']);
310+
311+
// mcpPromptCommands surfaces it as a slash command
312+
const cmds = mcpPromptCommands([handle]);
313+
expect(cmds[0]!.command).toBe('/mcp__gh__open_pr');
314+
315+
// resolveMcpPromptInvocation maps a positional token to the declared arg
316+
const inv = resolveMcpPromptInvocation('/mcp__gh__open_pr fix-bug', [handle]);
317+
expect(inv).not.toBeNull();
318+
expect(inv!.prompt).toBe('open_pr');
319+
expect(inv!.args).toEqual({ title: 'fix-bug' });
320+
321+
// getMcpPrompt returns the server's rendered prompt text + forwarded args
322+
const text = await getMcpPrompt(handle, inv!.prompt, inv!.args);
323+
expect(text).toContain('Draft a PR titled');
324+
expect(text).toContain('"title":"fix-bug"');
325+
} finally {
326+
await handle.close();
327+
}
328+
}, 20_000);
259329
});
260330

261331
describe('pickTransportKind', () => {
@@ -315,6 +385,44 @@ describe('parseResourceRefs', () => {
315385
});
316386
});
317387

388+
describe('resolveMcpPromptInvocation', () => {
389+
const handle = {
390+
serverName: 'srv',
391+
prompts: [
392+
{ name: 'greet', arguments: [{ name: 'who' }, { name: 'lang' }] },
393+
{ name: 'noargs' },
394+
],
395+
} as unknown as Parameters<typeof resolveMcpPromptInvocation>[1][number];
396+
397+
it('returns null for non-prompt lines', () => {
398+
expect(resolveMcpPromptInvocation('hello world', [handle])).toBeNull();
399+
expect(resolveMcpPromptInvocation('/help', [handle])).toBeNull();
400+
});
401+
402+
it('returns null for an unknown server or prompt', () => {
403+
expect(resolveMcpPromptInvocation('/mcp__other__greet', [handle])).toBeNull();
404+
expect(resolveMcpPromptInvocation('/mcp__srv__missing', [handle])).toBeNull();
405+
});
406+
407+
it('maps bare tokens positionally onto declared argument names', () => {
408+
const inv = resolveMcpPromptInvocation('/mcp__srv__greet Ada french', [handle]);
409+
expect(inv?.prompt).toBe('greet');
410+
expect(inv?.args).toEqual({ who: 'Ada', lang: 'french' });
411+
});
412+
413+
it('parses key=value tokens (and mixes with positional)', () => {
414+
const inv = resolveMcpPromptInvocation('/mcp__srv__greet lang=de Ada', [handle]);
415+
// lang set explicitly; bare "Ada" fills the first declared arg (who)
416+
expect(inv?.args).toEqual({ lang: 'de', who: 'Ada' });
417+
});
418+
419+
it('handles a prompt with no declared arguments', () => {
420+
const inv = resolveMcpPromptInvocation('/mcp__srv__noargs', [handle]);
421+
expect(inv?.prompt).toBe('noargs');
422+
expect(inv?.args).toEqual({});
423+
});
424+
});
425+
318426
// Silence unused-import warning — Server/Transport are used via the spawned script
319427
void Server;
320428
void StdioServerTransport;

packages/core/src/mcp/client.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export interface McpResourceMeta {
3636
mimeType?: string;
3737
}
3838

39+
/** A prompt a server exposes (from prompts/list). */
40+
export interface McpPromptMeta {
41+
name: string;
42+
description?: string;
43+
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
44+
}
45+
3946
export interface McpClientHandle {
4047
serverName: string;
4148
client: Client;
@@ -45,6 +52,8 @@ export interface McpClientHandle {
4552
tools: ToolHandler[];
4653
/** Resources the server advertised (empty if it has no `resources` capability). */
4754
resources: McpResourceMeta[];
55+
/** Prompts the server advertised (empty if it has no `prompts` capability). */
56+
prompts: McpPromptMeta[];
4857
close(): Promise<void>;
4958
}
5059

@@ -203,19 +212,127 @@ export async function connectMcpServer(
203212
}
204213
}
205214

215+
// Prompts (best-effort, capability-gated — same degradation as resources).
216+
let prompts: McpPromptMeta[] = [];
217+
if (client.getServerCapabilities()?.prompts) {
218+
try {
219+
const p = await client.listPrompts();
220+
prompts = (p.prompts ?? []).map((pr) => ({
221+
name: pr.name,
222+
description: pr.description,
223+
arguments: pr.arguments,
224+
}));
225+
} catch {
226+
/* server advertised prompts but list failed — degrade to none */
227+
}
228+
}
229+
206230
return {
207231
serverName,
208232
client,
209233
transport,
210234
transportKind: kind,
211235
tools,
212236
resources,
237+
prompts,
213238
async close() {
214239
await client.close();
215240
},
216241
};
217242
}
218243

244+
/**
245+
* Fetch an MCP prompt and flatten its messages to a single prompt string. Each
246+
* message's text content is concatenated (non-text content is skipped).
247+
*/
248+
export async function getMcpPrompt(
249+
handle: McpClientHandle,
250+
name: string,
251+
args: Record<string, string> = {},
252+
): Promise<string> {
253+
const result = await handle.client.getPrompt({ name, arguments: args });
254+
const parts = (result.messages ?? []).map((m) => {
255+
const c = m.content;
256+
if (
257+
c &&
258+
typeof c === 'object' &&
259+
'type' in c &&
260+
c.type === 'text' &&
261+
typeof c.text === 'string'
262+
) {
263+
return c.text;
264+
}
265+
return '';
266+
});
267+
return parts.filter(Boolean).join('\n\n');
268+
}
269+
270+
/** A server prompt surfaced as a `/mcp__<server>__<prompt>` slash command. */
271+
export interface McpPromptCommand {
272+
/** Slash command name, e.g. `/mcp__github__open_pr`. */
273+
command: string;
274+
server: string;
275+
prompt: string;
276+
description?: string;
277+
arguments: Array<{ name: string; description?: string; required?: boolean }>;
278+
}
279+
280+
/** Build the `/mcp__server__prompt` command list across all connected servers. */
281+
export function mcpPromptCommands(handles: McpClientHandle[]): McpPromptCommand[] {
282+
const out: McpPromptCommand[] = [];
283+
for (const h of handles) {
284+
for (const p of h.prompts) {
285+
out.push({
286+
command: `/mcp__${h.serverName}__${p.name}`,
287+
server: h.serverName,
288+
prompt: p.name,
289+
description: p.description,
290+
arguments: p.arguments ?? [],
291+
});
292+
}
293+
}
294+
return out;
295+
}
296+
297+
/**
298+
* Resolve a `/mcp__server__prompt …` REPL line: find the matching prompt and
299+
* parse its arguments. Args accept `key=value` tokens; bare tokens map
300+
* positionally onto the prompt's declared argument names. Returns null if the
301+
* line isn't an MCP-prompt invocation.
302+
*/
303+
export function resolveMcpPromptInvocation(
304+
line: string,
305+
handles: McpClientHandle[],
306+
): { handle: McpClientHandle; prompt: string; args: Record<string, string> } | null {
307+
const trimmed = line.trim();
308+
if (!trimmed.startsWith('/mcp__')) return null;
309+
const tokens = trimmed.split(/\s+/);
310+
const command = tokens[0]!; // /mcp__server__prompt
311+
const rest = command.slice('/mcp__'.length);
312+
const sep = rest.indexOf('__');
313+
if (sep === -1) return null;
314+
const server = rest.slice(0, sep);
315+
const promptName = rest.slice(sep + 2);
316+
const handle = handles.find((h) => h.serverName === server);
317+
if (!handle) return null;
318+
const meta = handle.prompts.find((p) => p.name === promptName);
319+
if (!meta) return null;
320+
321+
const declared = meta.arguments ?? [];
322+
const args: Record<string, string> = {};
323+
let positional = 0;
324+
for (const tok of tokens.slice(1)) {
325+
const eq = tok.indexOf('=');
326+
if (eq > 0) {
327+
args[tok.slice(0, eq)] = tok.slice(eq + 1);
328+
} else if (declared[positional]) {
329+
args[declared[positional]!.name] = tok;
330+
positional++;
331+
}
332+
}
333+
return { handle, prompt: promptName, args };
334+
}
335+
219336
/**
220337
* Read an MCP resource by URI and flatten its contents to text. Binary blobs are
221338
* rendered as a `[binary …]` placeholder (the model can't use raw base64).

packages/core/src/mcp/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ export {
1212
readMcpResource,
1313
parseResourceRefs,
1414
expandMcpResourceRefs,
15+
getMcpPrompt,
16+
mcpPromptCommands,
17+
resolveMcpPromptInvocation,
1518
type McpClientHandle,
1619
type McpToolMeta,
1720
type McpResourceMeta,
21+
type McpPromptMeta,
1822
type McpTransportKind,
1923
type ConnectAllResult,
2024
type ResourceRef,
2125
type ExpandResourcesResult,
26+
type McpPromptCommand,
2227
} from './client.js';
2328

2429
export {

0 commit comments

Comments
 (0)