Skip to content

Commit 77fa837

Browse files
committed
feat(mcp): server prompts as /mcp__server__name slash commands
Finishes the MCP surface (tools + resources + prompts). Both transports speak prompts/list + prompts/get; the manager aggregates prompts and, after connect, each registers as a /mcp__<server>__<name> command that expands the prompt (positional or key=value args) and submits it. /mcp lists them.
1 parent 4695788 commit 77fa837

12 files changed

Lines changed: 363 additions & 15 deletions

File tree

src/commands/builtins/mcp.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export const mcp: Command = {
5252
ctx.emit("Resources (read via read_mcp_resource):");
5353
for (const r of resources) ctx.emit(` ${r.server} :: ${r.descriptor.uri}`);
5454
}
55+
const prompts = ctx.bundle.mcp.prompts();
56+
if (prompts.length > 0) {
57+
ctx.emit("Prompts (run as slash commands):");
58+
for (const p of prompts) ctx.emit(` /mcp__${p.server}__${p.descriptor.name}`);
59+
}
5560
return { handled: true };
5661
},
5762
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
import { flattenPromptMessages, mcpPromptCommandName, parseArgs } from "./mcp-prompt-commands.js";
3+
4+
describe("mcpPromptCommandName", () => {
5+
it("namespaces server + prompt", () => {
6+
expect(mcpPromptCommandName("git", "changelog")).toBe("mcp__git__changelog");
7+
});
8+
});
9+
10+
describe("parseArgs", () => {
11+
const declared = [{ name: "topic" }, { name: "tone" }];
12+
13+
it("parses key=value pairs", () => {
14+
expect(parseArgs("topic=auth tone=terse", declared)).toEqual({ topic: "auth", tone: "terse" });
15+
});
16+
17+
it("maps positional args, last arg soaks the rest", () => {
18+
expect(parseArgs("auth be very terse", declared)).toEqual({ topic: "auth", tone: "be very terse" });
19+
});
20+
21+
it("puts a single arg in the first declared slot", () => {
22+
expect(parseArgs("auth", declared)).toEqual({ topic: "auth" });
23+
});
24+
25+
it("falls back to {input} when nothing is declared", () => {
26+
expect(parseArgs("whatever they typed", undefined)).toEqual({ input: "whatever they typed" });
27+
});
28+
29+
it("returns {} for empty args", () => {
30+
expect(parseArgs(" ", declared)).toEqual({});
31+
});
32+
});
33+
34+
describe("flattenPromptMessages", () => {
35+
it("joins user messages plainly and labels other roles", () => {
36+
const text = flattenPromptMessages([
37+
{ role: "user", content: "do the thing" },
38+
{ role: "assistant", content: { type: "text", text: "context" } },
39+
]);
40+
expect(text).toBe("do the thing\n\n[assistant]\ncontext");
41+
});
42+
43+
it("flattens content-block arrays", () => {
44+
const text = flattenPromptMessages([
45+
{
46+
role: "user",
47+
content: [
48+
{ type: "text", text: "a" },
49+
{ type: "text", text: "b" },
50+
],
51+
},
52+
]);
53+
expect(text).toBe("a\nb");
54+
});
55+
56+
it("returns empty for missing messages", () => {
57+
expect(flattenPromptMessages(undefined)).toBe("");
58+
});
59+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { McpManager, McpPromptRef } from "../mcp/manager.js";
2+
import type { McpContentBlock } from "../mcp/protocol.js";
3+
import type { CommandRegistry } from "./registry.js";
4+
import type { Command } from "./types.js";
5+
6+
/** MCP prompt → slash command name: `mcp__<server>__<prompt>`, mirroring tool naming. */
7+
export function mcpPromptCommandName(server: string, prompt: string): string {
8+
return `mcp__${server}__${prompt}`;
9+
}
10+
11+
const VALID = /^[a-z0-9][a-z0-9_-]*$/i;
12+
13+
/**
14+
* Bridge connected MCP prompts into slash commands. `/mcp__<server>__<name>
15+
* [args]` calls prompts/get on the server, flattens the returned messages
16+
* into a single prompt, and submits it to the agent. Arguments are parsed
17+
* positionally against the prompt's declared `arguments`, or as
18+
* `key=value` pairs. Names that collide with a built-in or are unsafe for
19+
* a command are skipped.
20+
*/
21+
export function buildMcpPromptCommands(
22+
prompts: readonly McpPromptRef[],
23+
mcp: McpManager,
24+
registry: CommandRegistry,
25+
): Command[] {
26+
const out: Command[] = [];
27+
for (const { server, descriptor } of prompts) {
28+
const name = mcpPromptCommandName(server, descriptor.name);
29+
if (!VALID.test(name) || registry.get(name)) continue;
30+
out.push({
31+
name,
32+
description: descriptor.description
33+
? `${descriptor.description} (MCP prompt)`
34+
: `MCP prompt ${descriptor.name}.`,
35+
handler: (args, ctx) => {
36+
if (ctx.state.status !== "idle" && ctx.state.status !== "error" && ctx.state.status !== "aborted") {
37+
ctx.emit(`agent is busy — run /${name} after this turn settles.`);
38+
return { handled: true };
39+
}
40+
const parsed = parseArgs(args, descriptor.arguments);
41+
void mcp
42+
.getPrompt(server, descriptor.name, parsed)
43+
.then((result) => {
44+
const text = flattenPromptMessages(result.messages);
45+
if (!text.trim()) {
46+
ctx.emit(`MCP prompt "${descriptor.name}" returned nothing.`);
47+
return;
48+
}
49+
return ctx.bundle.submitUserPrompt(text).then((r) => {
50+
if (!r.submitted) ctx.emit(`prompt blocked: ${r.reason ?? "refused by hook"}`);
51+
else if (r.error) ctx.emit(`agent error: ${r.error}`);
52+
});
53+
})
54+
.catch((err) => ctx.emit(`MCP prompt failed: ${err instanceof Error ? err.message : String(err)}`));
55+
return { handled: true };
56+
},
57+
});
58+
}
59+
return out;
60+
}
61+
62+
/** Map user args to the prompt's declared arguments — `key=value` pairs first, else positional. */
63+
export function parseArgs(raw: string, declared: McpPromptRef["descriptor"]["arguments"]): Record<string, string> {
64+
const trimmed = raw.trim();
65+
if (!trimmed) return {};
66+
const tokens = trimmed.split(/\s+/);
67+
const names = (declared ?? []).map((a) => a.name);
68+
69+
// All tokens look like key=value → keyed.
70+
if (tokens.every((t) => /^[^=\s]+=/.test(t))) {
71+
const out: Record<string, string> = {};
72+
for (const t of tokens) {
73+
const eq = t.indexOf("=");
74+
out[t.slice(0, eq)] = t.slice(eq + 1);
75+
}
76+
return out;
77+
}
78+
79+
// Positional against declared names; a single undeclared arg → first slot.
80+
if (names.length === 0) return { input: trimmed };
81+
const out: Record<string, string> = {};
82+
names.forEach((n, i) => {
83+
if (i < names.length - 1) {
84+
if (tokens[i] !== undefined) out[n] = tokens[i];
85+
} else {
86+
// Last declared arg soaks up the remaining tokens.
87+
const rest = tokens.slice(i).join(" ");
88+
if (rest) out[n] = rest;
89+
}
90+
});
91+
return out;
92+
}
93+
94+
/** Flatten prompt messages into a single text prompt; non-user roles are labeled. */
95+
export function flattenPromptMessages(
96+
messages: Array<{ role: string; content: string | McpContentBlock | McpContentBlock[] }> | undefined,
97+
): string {
98+
if (!Array.isArray(messages)) return "";
99+
const parts: string[] = [];
100+
for (const m of messages) {
101+
const text = contentToText(m.content);
102+
if (!text) continue;
103+
parts.push(m.role === "user" ? text : `[${m.role}]\n${text}`);
104+
}
105+
return parts.join("\n\n");
106+
}
107+
108+
function contentToText(content: string | McpContentBlock | McpContentBlock[]): string {
109+
if (typeof content === "string") return content;
110+
const blocks = Array.isArray(content) ? content : [content];
111+
return blocks
112+
.map((b) => (b.type === "text" && typeof b.text === "string" ? b.text : `[${b.type} content]`))
113+
.join("\n");
114+
}

src/mcp/__test__/mock-server.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ rl.on("line", (line) => {
2323
id: msg.id,
2424
result: {
2525
protocolVersion: "2025-06-18",
26-
capabilities: { tools: {}, resources: {} },
26+
capabilities: { tools: {}, resources: {}, prompts: {} },
2727
serverInfo: { name: "mock", version: "0" },
2828
},
2929
});
@@ -80,6 +80,25 @@ rl.on("line", (line) => {
8080
});
8181
return;
8282
}
83+
if (msg.method === "prompts/list") {
84+
send({
85+
jsonrpc: "2.0",
86+
id: msg.id,
87+
result: {
88+
prompts: [{ name: "greet", description: "Greet someone", arguments: [{ name: "who", required: true }] }],
89+
},
90+
});
91+
return;
92+
}
93+
if (msg.method === "prompts/get") {
94+
const who = msg.params?.arguments?.who ?? "world";
95+
send({
96+
jsonrpc: "2.0",
97+
id: msg.id,
98+
result: { messages: [{ role: "user", content: { type: "text", text: `Say hello to ${who}.` } }] },
99+
});
100+
return;
101+
}
83102
// Unknown method → error response.
84103
if (typeof msg.id === "number") {
85104
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unknown method: ${msg.method}` } });

src/mcp/client.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { McpCallToolResult, McpReadResourceResult, McpResourceDescriptor, McpToolDescriptor } from "./protocol.js";
1+
import type {
2+
McpCallToolResult,
3+
McpGetPromptResult,
4+
McpPromptDescriptor,
5+
McpReadResourceResult,
6+
McpResourceDescriptor,
7+
McpToolDescriptor,
8+
} from "./protocol.js";
29

310
/** What every MCP client speaks to, regardless of transport (stdio | http). */
411
export const CLIENT_INFO = { name: "codebase-cli", version: "1" } as const;
@@ -23,6 +30,10 @@ export interface McpClient {
2330
listResources(): Promise<McpResourceDescriptor[]>;
2431
/** Read one resource by URI. */
2532
readResource(uri: string): Promise<McpReadResourceResult>;
33+
/** List the server's prompts. Returns [] when the server has no prompts capability. */
34+
listPrompts(): Promise<McpPromptDescriptor[]>;
35+
/** Expand a prompt by name with arguments. */
36+
getPrompt(name: string, args: Record<string, string>): Promise<McpGetPromptResult>;
2637
/** Tear down the transport and reject any in-flight requests. */
2738
close(): void;
2839
}

src/mcp/http-client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
type JsonRpcResponse,
66
MCP_PROTOCOL_VERSION,
77
type McpCallToolResult,
8+
type McpGetPromptResult,
9+
type McpPromptDescriptor,
810
type McpReadResourceResult,
911
type McpResourceDescriptor,
1012
type McpToolDescriptor,
@@ -84,6 +86,21 @@ export class HttpMcpClient implements McpClient {
8486
return (res.result as McpReadResourceResult) ?? {};
8587
}
8688

89+
async listPrompts(): Promise<McpPromptDescriptor[]> {
90+
try {
91+
const res = await this.request("prompts/list", {});
92+
const result = res.result as { prompts?: McpPromptDescriptor[] } | undefined;
93+
return Array.isArray(result?.prompts) ? result.prompts : [];
94+
} catch {
95+
return [];
96+
}
97+
}
98+
99+
async getPrompt(name: string, args: Record<string, string>): Promise<McpGetPromptResult> {
100+
const res = await this.request("prompts/get", { name, arguments: args });
101+
return (res.result as McpGetPromptResult) ?? {};
102+
}
103+
87104
close(): void {
88105
if (this.closed) return;
89106
this.closed = true;

src/mcp/manager.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ describe("McpManager (against the mock server)", () => {
9393
expect(read.contents?.[0]).toMatchObject({ text: "hello from mock" });
9494
});
9595

96+
it("aggregates prompts and expands one with arguments", async () => {
97+
writeConfig({ demo: { command: process.execPath, args: [MOCK] } });
98+
manager = new McpManager();
99+
await manager.connectAll({ home, cwd });
100+
101+
const prompts = manager.prompts();
102+
expect(prompts).toHaveLength(1);
103+
expect(prompts[0]).toMatchObject({ server: "demo", descriptor: { name: "greet" } });
104+
105+
const got = await manager.getPrompt("demo", "greet", { who: "Ada" });
106+
expect(got.messages?.[0]).toMatchObject({ role: "user", content: { text: "Say hello to Ada." } });
107+
});
108+
96109
it("connects a remote (url) server over HTTP and bridges its tools", async () => {
97110
const server = await startHttpMock();
98111
try {

src/mcp/manager.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { HttpMcpClient } from "./http-client.js";
55
import type { AuthorizeDeps } from "./oauth/flow.js";
66
import { McpOAuthProvider } from "./oauth/provider.js";
77
import { McpOAuthStore } from "./oauth/store.js";
8-
import type { McpReadResourceResult, McpResourceDescriptor } from "./protocol.js";
8+
import type {
9+
McpGetPromptResult,
10+
McpPromptDescriptor,
11+
McpReadResourceResult,
12+
McpResourceDescriptor,
13+
} from "./protocol.js";
914
import { StdioMcpClient } from "./stdio-client.js";
1015
import { mcpToAgentTool } from "./to-agent-tool.js";
1116

@@ -22,6 +27,12 @@ export interface McpResourceRef {
2227
descriptor: McpResourceDescriptor;
2328
}
2429

30+
/** A prompt plus the server that exposes it. */
31+
export interface McpPromptRef {
32+
server: string;
33+
descriptor: McpPromptDescriptor;
34+
}
35+
2536
export interface McpManagerOptions {
2637
/** Persisted OAuth sessions for remote servers. Defaults to ~/.codebase/mcp-credentials.json. */
2738
oauthStore?: McpOAuthStore;
@@ -45,6 +56,7 @@ export class McpManager {
4556
private readonly statuses: McpServerStatus[] = [];
4657
private readonly toolList: AgentTool<any>[] = [];
4758
private readonly resourceList: McpResourceRef[] = [];
59+
private readonly promptList: McpPromptRef[] = [];
4860
private readonly oauthStore: McpOAuthStore;
4961
private readonly authDeps: AuthorizeDeps;
5062

@@ -72,10 +84,12 @@ export class McpManager {
7284
for (const desc of descriptors) {
7385
this.toolList.push(mcpToAgentTool(name, client, desc));
7486
}
75-
// Resources are best-effort — a server without the capability
76-
// returns [], and a failure here never blocks its tools.
87+
// Resources + prompts are best-effort — a server without the
88+
// capability returns [], and a failure never blocks its tools.
7789
const resources = await client.listResources().catch(() => []);
7890
for (const r of resources) this.resourceList.push({ server: name, descriptor: r });
91+
const prompts = await client.listPrompts().catch(() => []);
92+
for (const p of prompts) this.promptList.push({ server: name, descriptor: p });
7993
this.statuses.push({ name, connected: true, toolCount: descriptors.length });
8094
} catch (err) {
8195
client.close();
@@ -112,6 +126,18 @@ export class McpManager {
112126
return client.readResource(uri);
113127
}
114128

129+
/** Every prompt discovered across connected servers. */
130+
prompts(): readonly McpPromptRef[] {
131+
return this.promptList;
132+
}
133+
134+
/** Expand a prompt by server + name with arguments. Throws if the server isn't connected. */
135+
async getPrompt(server: string, name: string, args: Record<string, string>): Promise<McpGetPromptResult> {
136+
const client = this.clientsByName.get(server);
137+
if (!client) throw new Error(`MCP server "${server}" is not connected`);
138+
return client.getPrompt(name, args);
139+
}
140+
115141
/** Terminate every server connection. Idempotent. */
116142
dispose(): void {
117143
for (const client of this.clients) client.close();

src/mcp/protocol.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,25 @@ export interface McpReadResourceResult {
7575
[key: string]: unknown;
7676
}
7777

78+
/** A prompt as described by a server's prompts/list response. */
79+
export interface McpPromptDescriptor {
80+
name: string;
81+
description?: string;
82+
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
83+
}
84+
85+
/** One message of a prompts/get result. Content is text or a content-block array. */
86+
export interface McpPromptMessage {
87+
role: string;
88+
content: string | McpContentBlock | McpContentBlock[];
89+
}
90+
91+
export interface McpGetPromptResult {
92+
description?: string;
93+
messages?: McpPromptMessage[];
94+
[key: string]: unknown;
95+
}
96+
7897
/** Parse one line of stdio output into a JSON-RPC message, or null if unparseable. */
7998
export function parseRpcLine(line: string): JsonRpcResponse | JsonRpcNotification | null {
8099
const trimmed = line.trim();

0 commit comments

Comments
 (0)