Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
closeAllMcpServers,
connectAllMcpServers,
expandMcpResourceRefs,
getMcpPrompt,
mcpPromptCommands,
resolveMcpPromptInvocation,
expandCommandBody,
findCustomCommand,
findStyle,
Expand Down Expand Up @@ -190,11 +193,18 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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`);
Expand Down Expand Up @@ -320,6 +330,21 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
continue;
}

// MCP prompt command (`/mcp__<server>__<prompt> [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/<name>.md)? Expand its
// body with the args and submit it to the agent as the user prompt.
if (userInput.trim().startsWith('/')) {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
110 changes: 109 additions & 1 deletion packages/core/src/mcp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import {
connectAllMcpServers,
connectMcpServer,
expandMcpResourceRefs,
getMcpPrompt,
mcpPromptCommands,
parseHelperOutput,
parseResourceRefs,
pickTransportKind,
readMcpResource,
resolveMcpPromptInvocation,
} from './client.js';

const require_ = createRequire(import.meta.url);
Expand All @@ -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<string> {
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}';
Expand All @@ -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(
Expand All @@ -79,6 +110,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
};
});
${resourceBlock}
${promptBlock}
await server.connect(new StdioServerTransport());
`,
'utf8',
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<typeof resolveMcpPromptInvocation>[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;
Expand Down
117 changes: 117 additions & 0 deletions packages/core/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<void>;
}

Expand Down Expand Up @@ -203,19 +212,127 @@ 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,
transport,
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<string, string> = {},
): Promise<string> {
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__<server>__<prompt>` 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<string, string> } | 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<string, string> = {};
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).
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading