From c7e03ed47a8c73f4e526bf1716d1d5c4a521886d Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 31 May 2026 15:24:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20prompts=20as=20slash=20commands=20?= =?UTF-8?q?=E2=80=94=20/mcp=5F=5Fserver=5F=5Fprompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes MCP feature parity (DEVELOPMENT_PLAN §3.3): a server's prompts are listed on connect and invokable as `/mcp____ [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) --- apps/cli/src/repl.ts | 25 ++++++ packages/core/src/index.ts | 5 ++ packages/core/src/mcp/client.test.ts | 110 ++++++++++++++++++++++++- packages/core/src/mcp/client.ts | 117 +++++++++++++++++++++++++++ packages/core/src/mcp/index.ts | 5 ++ 5 files changed, 261 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index f3bc50d..df41158 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -18,6 +18,9 @@ import { closeAllMcpServers, connectAllMcpServers, expandMcpResourceRefs, + getMcpPrompt, + mcpPromptCommands, + resolveMcpPromptInvocation, expandCommandBody, findCustomCommand, findStyle, @@ -190,11 +193,18 @@ export async function startRepl(opts: ReplOpts): Promise { const deferredNames = installToolSearch(tools, deferredMcpTools); if (mcpServers.length > 0) { const eager = mcpServers.reduce((n, h) => n + h.tools.length, 0) - deferredNames.length; + const resourceCount = mcpServers.reduce((n, h) => n + h.resources.length, 0); + const promptCmds = mcpPromptCommands(mcpServers); output.write( ` ⊞ MCP: ${mcpServers.length} server(s) connected (${eager} tools` + (deferredNames.length > 0 ? `, ${deferredNames.length} deferred behind ToolSearch` : '') + + (resourceCount > 0 ? `, ${resourceCount} resources` : '') + + (promptCmds.length > 0 ? `, ${promptCmds.length} prompts` : '') + `)\n`, ); + if (promptCmds.length > 0) { + output.write(` ⊞ MCP prompts: ${promptCmds.map((c) => c.command).join(', ')}\n`); + } } if (mcpErrors.length > 0) { output.write(` ⊞ MCP: ${mcpErrors.length} server(s) failed (see /mcp)\n`); @@ -320,6 +330,21 @@ export async function startRepl(opts: ReplOpts): Promise { continue; } + // MCP prompt command (`/mcp____ [args]`)? Fetch the rendered + // prompt from the server and submit it as the user prompt. + if (userInput.trim().startsWith('/mcp__') && mcpServers.length > 0) { + const inv = resolveMcpPromptInvocation(userInput, mcpServers); + if (inv) { + try { + userInput = await getMcpPrompt(inv.handle, inv.prompt, inv.args); + output.write(` ▸ /mcp__${inv.handle.serverName}__${inv.prompt} (MCP prompt)\n\n`); + } catch (err) { + output.write(` ⚠ MCP prompt failed: ${(err as Error).message}\n`); + continue; + } + } + } + // Custom prompt-template command (.deepcode/commands/.md)? Expand its // body with the args and submit it to the agent as the user prompt. if (userInput.trim().startsWith('/')) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e30da95..ea9de30 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -195,14 +195,19 @@ export { readMcpResource, parseResourceRefs, expandMcpResourceRefs, + getMcpPrompt, + mcpPromptCommands, + resolveMcpPromptInvocation, type McpClientHandle, type McpToolMeta, type McpResourceMeta, + type McpPromptMeta, type ConnectAllResult, type BuildMcpServerOpts, type ServeMcpStdioOpts, type ResourceRef, type ExpandResourcesResult, + type McpPromptCommand, } from './mcp/index.js'; // Plugins (M5 — manifest + hash pin; M5.1 — subprocess runtime + RPC bridge; diff --git a/packages/core/src/mcp/client.test.ts b/packages/core/src/mcp/client.test.ts index 5c9f5dc..2bfdb43 100644 --- a/packages/core/src/mcp/client.test.ts +++ b/packages/core/src/mcp/client.test.ts @@ -14,10 +14,13 @@ import { connectAllMcpServers, connectMcpServer, expandMcpResourceRefs, + getMcpPrompt, + mcpPromptCommands, parseHelperOutput, parseResourceRefs, pickTransportKind, readMcpResource, + resolveMcpPromptInvocation, } from './client.js'; const require_ = createRequire(import.meta.url); @@ -40,9 +43,18 @@ async function writeFakeServer( name: string, tools: object[], resources?: Array<{ uri: string; name?: string; text: string; mimeType?: string }>, + prompts?: Array<{ + name: string; + description?: string; + arguments?: Array<{ name: string; required?: boolean }>; + text: string; + }>, ): Promise { const serverPath = join(dir, `${name}.mjs`); - const caps = resources ? '{ tools: {}, resources: {} }' : '{ tools: {} }'; + const capList = ['tools: {}']; + if (resources) capList.push('resources: {}'); + if (prompts) capList.push('prompts: {}'); + const caps = `{ ${capList.join(', ')} }`; const resourceBlock = resources ? ` import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '${TYPES_INDEX}'; @@ -55,6 +67,25 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => { if (!found) throw new Error('no such resource: ' + req.params.uri); return { contents: [{ uri: found.uri, mimeType: found.mimeType ?? 'text/plain', text: found.text }] }; }); +` + : ''; + const promptBlock = prompts + ? ` +import { ListPromptsRequestSchema, GetPromptRequestSchema } from '${TYPES_INDEX}'; +const PROMPTS = ${JSON.stringify(prompts)}; +server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: PROMPTS.map((p) => ({ name: p.name, description: p.description, arguments: p.arguments })), +})); +server.setRequestHandler(GetPromptRequestSchema, async (req) => { + const found = PROMPTS.find((p) => p.name === req.params.name); + if (!found) throw new Error('no such prompt: ' + req.params.name); + const argsStr = JSON.stringify(req.params.arguments ?? {}); + return { + messages: [ + { role: 'user', content: { type: 'text', text: found.text + ' args=' + argsStr } }, + ], + }; +}); ` : ''; await fs.writeFile( @@ -79,6 +110,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { }; }); ${resourceBlock} +${promptBlock} await server.connect(new StdioServerTransport()); `, 'utf8', @@ -256,6 +288,44 @@ describe('MCP client', () => { await handle.close(); } }, 20_000); + + it('lists prompts on connect and fetches one with arguments', async () => { + const serverScript = await writeFakeServer( + tmp, + 'gh', + [{ name: 'noop', description: 'd', inputSchema: { type: 'object', properties: {} } }], + undefined, + [ + { + name: 'open_pr', + description: 'Open a PR', + arguments: [{ name: 'title', required: true }], + text: 'Draft a PR titled', + }, + ], + ); + const handle = await connectMcpServer('gh', { command: 'node', args: [serverScript] }); + try { + expect(handle.prompts.map((p) => p.name)).toEqual(['open_pr']); + + // mcpPromptCommands surfaces it as a slash command + const cmds = mcpPromptCommands([handle]); + expect(cmds[0]!.command).toBe('/mcp__gh__open_pr'); + + // resolveMcpPromptInvocation maps a positional token to the declared arg + const inv = resolveMcpPromptInvocation('/mcp__gh__open_pr fix-bug', [handle]); + expect(inv).not.toBeNull(); + expect(inv!.prompt).toBe('open_pr'); + expect(inv!.args).toEqual({ title: 'fix-bug' }); + + // getMcpPrompt returns the server's rendered prompt text + forwarded args + const text = await getMcpPrompt(handle, inv!.prompt, inv!.args); + expect(text).toContain('Draft a PR titled'); + expect(text).toContain('"title":"fix-bug"'); + } finally { + await handle.close(); + } + }, 20_000); }); describe('pickTransportKind', () => { @@ -315,6 +385,44 @@ describe('parseResourceRefs', () => { }); }); +describe('resolveMcpPromptInvocation', () => { + const handle = { + serverName: 'srv', + prompts: [ + { name: 'greet', arguments: [{ name: 'who' }, { name: 'lang' }] }, + { name: 'noargs' }, + ], + } as unknown as Parameters[1][number]; + + it('returns null for non-prompt lines', () => { + expect(resolveMcpPromptInvocation('hello world', [handle])).toBeNull(); + expect(resolveMcpPromptInvocation('/help', [handle])).toBeNull(); + }); + + it('returns null for an unknown server or prompt', () => { + expect(resolveMcpPromptInvocation('/mcp__other__greet', [handle])).toBeNull(); + expect(resolveMcpPromptInvocation('/mcp__srv__missing', [handle])).toBeNull(); + }); + + it('maps bare tokens positionally onto declared argument names', () => { + const inv = resolveMcpPromptInvocation('/mcp__srv__greet Ada french', [handle]); + expect(inv?.prompt).toBe('greet'); + expect(inv?.args).toEqual({ who: 'Ada', lang: 'french' }); + }); + + it('parses key=value tokens (and mixes with positional)', () => { + const inv = resolveMcpPromptInvocation('/mcp__srv__greet lang=de Ada', [handle]); + // lang set explicitly; bare "Ada" fills the first declared arg (who) + expect(inv?.args).toEqual({ lang: 'de', who: 'Ada' }); + }); + + it('handles a prompt with no declared arguments', () => { + const inv = resolveMcpPromptInvocation('/mcp__srv__noargs', [handle]); + expect(inv?.prompt).toBe('noargs'); + expect(inv?.args).toEqual({}); + }); +}); + // Silence unused-import warning — Server/Transport are used via the spawned script void Server; void StdioServerTransport; diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 1486316..adf2a48 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -36,6 +36,13 @@ export interface McpResourceMeta { mimeType?: string; } +/** A prompt a server exposes (from prompts/list). */ +export interface McpPromptMeta { + name: string; + description?: string; + arguments?: Array<{ name: string; description?: string; required?: boolean }>; +} + export interface McpClientHandle { serverName: string; client: Client; @@ -45,6 +52,8 @@ export interface McpClientHandle { tools: ToolHandler[]; /** Resources the server advertised (empty if it has no `resources` capability). */ resources: McpResourceMeta[]; + /** Prompts the server advertised (empty if it has no `prompts` capability). */ + prompts: McpPromptMeta[]; close(): Promise; } @@ -203,6 +212,21 @@ export async function connectMcpServer( } } + // Prompts (best-effort, capability-gated — same degradation as resources). + let prompts: McpPromptMeta[] = []; + if (client.getServerCapabilities()?.prompts) { + try { + const p = await client.listPrompts(); + prompts = (p.prompts ?? []).map((pr) => ({ + name: pr.name, + description: pr.description, + arguments: pr.arguments, + })); + } catch { + /* server advertised prompts but list failed — degrade to none */ + } + } + return { serverName, client, @@ -210,12 +234,105 @@ export async function connectMcpServer( transportKind: kind, tools, resources, + prompts, async close() { await client.close(); }, }; } +/** + * Fetch an MCP prompt and flatten its messages to a single prompt string. Each + * message's text content is concatenated (non-text content is skipped). + */ +export async function getMcpPrompt( + handle: McpClientHandle, + name: string, + args: Record = {}, +): Promise { + const result = await handle.client.getPrompt({ name, arguments: args }); + const parts = (result.messages ?? []).map((m) => { + const c = m.content; + if ( + c && + typeof c === 'object' && + 'type' in c && + c.type === 'text' && + typeof c.text === 'string' + ) { + return c.text; + } + return ''; + }); + return parts.filter(Boolean).join('\n\n'); +} + +/** A server prompt surfaced as a `/mcp____` slash command. */ +export interface McpPromptCommand { + /** Slash command name, e.g. `/mcp__github__open_pr`. */ + command: string; + server: string; + prompt: string; + description?: string; + arguments: Array<{ name: string; description?: string; required?: boolean }>; +} + +/** Build the `/mcp__server__prompt` command list across all connected servers. */ +export function mcpPromptCommands(handles: McpClientHandle[]): McpPromptCommand[] { + const out: McpPromptCommand[] = []; + for (const h of handles) { + for (const p of h.prompts) { + out.push({ + command: `/mcp__${h.serverName}__${p.name}`, + server: h.serverName, + prompt: p.name, + description: p.description, + arguments: p.arguments ?? [], + }); + } + } + return out; +} + +/** + * Resolve a `/mcp__server__prompt …` REPL line: find the matching prompt and + * parse its arguments. Args accept `key=value` tokens; bare tokens map + * positionally onto the prompt's declared argument names. Returns null if the + * line isn't an MCP-prompt invocation. + */ +export function resolveMcpPromptInvocation( + line: string, + handles: McpClientHandle[], +): { handle: McpClientHandle; prompt: string; args: Record } | null { + const trimmed = line.trim(); + if (!trimmed.startsWith('/mcp__')) return null; + const tokens = trimmed.split(/\s+/); + const command = tokens[0]!; // /mcp__server__prompt + const rest = command.slice('/mcp__'.length); + const sep = rest.indexOf('__'); + if (sep === -1) return null; + const server = rest.slice(0, sep); + const promptName = rest.slice(sep + 2); + const handle = handles.find((h) => h.serverName === server); + if (!handle) return null; + const meta = handle.prompts.find((p) => p.name === promptName); + if (!meta) return null; + + const declared = meta.arguments ?? []; + const args: Record = {}; + let positional = 0; + for (const tok of tokens.slice(1)) { + const eq = tok.indexOf('='); + if (eq > 0) { + args[tok.slice(0, eq)] = tok.slice(eq + 1); + } else if (declared[positional]) { + args[declared[positional]!.name] = tok; + positional++; + } + } + return { handle, prompt: promptName, args }; +} + /** * Read an MCP resource by URI and flatten its contents to text. Binary blobs are * rendered as a `[binary …]` placeholder (the model can't use raw base64). diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 9148154..b5dbcaf 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -12,13 +12,18 @@ export { readMcpResource, parseResourceRefs, expandMcpResourceRefs, + getMcpPrompt, + mcpPromptCommands, + resolveMcpPromptInvocation, type McpClientHandle, type McpToolMeta, type McpResourceMeta, + type McpPromptMeta, type McpTransportKind, type ConnectAllResult, type ResourceRef, type ExpandResourcesResult, + type McpPromptCommand, } from './client.js'; export {