Skip to content

Commit efa7e26

Browse files
committed
feat(mcp): stdio MCP client — connect external tool servers
Adds Model Context Protocol support so users extend the agent with external tools (filesystem, Postgres, git, fetch, …) without us writing each integration. v1 is stdio transport; remote HTTP/SSE is a planned follow-up. No SDK dependency. The official @modelcontextprotocol/sdk is 4.3MB + 17 transitive deps (express, hono, cors, jose, ajv…) — almost all for running a server + OAuth, none of which a stdio CLIENT needs. The client is a hand-rolled newline-delimited JSON-RPC 2.0 stdio client (src/mcp/), consistent with our minimal-deps tenet and the app-server's existing JSON-RPC-over-stdio pattern. Pieces: - protocol.ts: JSON-RPC + MCP message types, line parser - stdio-client.ts: spawn + handshake (initialize → initialized) + tools/list + tools/call, request/response correlation by id, timeout + crash → reject-all - config.ts: load ~/.codebase/mcp.json + project, de-facto mcpServers schema (ports existing Claude Desktop/Cursor configs), skips remote url servers with a note - to-agent-tool.ts: bridge MCP tool → AgentTool. Server JSON Schema becomes parameters verbatim (pi-agent-core forwards args without TypeBox validation); namespaced mcp__<server>__<tool> so no collision with built-ins - manager.ts: spawn all servers best-effort, collect tools, expose status; one broken server never blocks others - bundle.connectMcp() spliced into the live agent.state.tools after mount (createAgent is sync, connect is async); wired into ink + pi-tui + headless + app-server, disposed on exit and re-connected on /model - /mcp command shows status + tools Remote tools route through the same effect-based permission prompts. 22 tests including a real mock-server subprocess exercising the full handshake, round-trip, error + crash paths.
1 parent 43650a0 commit efa7e26

17 files changed

Lines changed: 921 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,45 @@ Monitors auto-clean when the watched shell exits or when `max_matches`
439439
is reached. The background shell itself keeps running until
440440
`shell_kill``monitor_stop` only unsubscribes from notifications.
441441

442+
## MCP (Model Context Protocol)
443+
444+
Connect external tool servers (filesystem, Postgres, git, fetch, …)
445+
without us writing each integration. v1 supports **stdio** servers;
446+
remote HTTP/SSE is a planned follow-up. No SDK dependency — the client
447+
is a hand-rolled newline-delimited JSON-RPC 2.0 stdio client
448+
(`src/mcp/`), consistent with our minimal-deps tenet and the
449+
app-server's existing JSON-RPC pattern.
450+
451+
Configure in `~/.codebase/mcp.json` (user) or `<cwd>/.codebase/mcp.json`
452+
(project, wins on name clash) — the de-facto `mcpServers` schema so
453+
existing Claude Desktop / Cursor configs port over:
454+
455+
```json
456+
{
457+
"mcpServers": {
458+
"filesystem": {
459+
"command": "npx",
460+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
461+
},
462+
"postgres": {
463+
"command": "uvx",
464+
"args": ["mcp-server-postgres"],
465+
"env": { "DATABASE_URL": "postgres://…" }
466+
}
467+
}
468+
}
469+
```
470+
471+
Servers connect after launch (async subprocess spawn + handshake);
472+
their tools splice into the live agent namespaced as
473+
`mcp__<server>__<tool>` so they can't collide with built-ins. A broken
474+
server is recorded and skipped — never blocks the others. `/mcp` shows
475+
status + tools. Remote MCP tools route through the same effect-based
476+
permission prompts. Lifecycle: connected on mount, re-connected on a
477+
`/model` swap, torn down on exit.
478+
442479
## In-flight features
443480

444-
- **MCP**: real MCP client support hasn't shipped (the `/mcp`
445-
placeholder was removed). The pi-mono roadmap will likely add this.
446481
- **Skills**: bundled + platform-fetched skills work; local
447482
user skills (`~/.codebase/skills/*.md`) coming.
483+
- **MCP remote transport**: HTTP/SSE servers + OAuth — stdio ships now.

src/agent/agent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ConfigStore } from "../config/store.js";
1212
import { DiagnosticsEngine, formatDiagnostics } from "../diagnostics/engine.js";
1313
import { GlueClient, resolveGlueModels } from "../glue/client.js";
1414
import { HookManager } from "../hooks/manager.js";
15+
import { McpManager, type McpServerStatus } from "../mcp/manager.js";
1516
import { buildMemoryAddendum } from "../memory/inject.js";
1617
import { MemoryStore } from "../memory/store.js";
1718
import { PermissionStore } from "../permissions/store.js";
@@ -133,6 +134,16 @@ export interface AgentBundle {
133134
/** Push-style line monitors over background shells. App subscribes
134135
* here and steers matched lines into the agent as system-reminders. */
135136
monitors: MonitorStore;
137+
/** MCP server manager — configured stdio servers + their bridged tools. */
138+
mcp: McpManager;
139+
/**
140+
* Connect configured MCP servers and splice their tools into the live
141+
* agent. Async (spawns subprocesses + handshake), so callers run it
142+
* after the bundle is built rather than blocking createAgent. Returns
143+
* the per-server connection status for display. Safe no-op when no
144+
* mcp.json exists.
145+
*/
146+
connectMcp: () => Promise<readonly McpServerStatus[]>;
136147
}
137148

138149
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
@@ -460,6 +471,20 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
460471
return { submitted: true };
461472
};
462473

474+
// MCP: configured stdio servers are connected lazily after the bundle
475+
// is built (connect is async; createAgent is sync). On connect we
476+
// splice the bridged tools into the live agent's tool set so the
477+
// model can call them from the next turn.
478+
const mcp = new McpManager();
479+
const connectMcp = async (): Promise<readonly McpServerStatus[]> => {
480+
await mcp.connectAll({ cwd });
481+
const mcpTools = mcp.tools();
482+
if (mcpTools.length > 0) {
483+
agent.state.tools = [...agent.state.tools, ...mcpTools];
484+
}
485+
return mcp.status();
486+
};
487+
463488
void agentRef;
464489
return {
465490
agent,
@@ -483,6 +508,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
483508
resumedMessages: opts.initialMessages ?? resumed?.messages ?? [],
484509
backgroundShells: toolContext.backgroundShells,
485510
monitors: toolContext.monitors,
511+
mcp,
512+
connectMcp,
486513
};
487514
}
488515

src/app-server/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export async function runAppServer(opts: AppServerOptions = {}): Promise<number>
7676
return fatal(msg);
7777
}
7878

79+
// Connect configured MCP servers so IDE-bridge sessions get the same
80+
// tool surface. Best-effort; failures are logged, never fatal.
81+
await bundle.connectMcp().catch((e) => {
82+
stderr.write(`app-server: MCP connect failed: ${e instanceof Error ? e.message : String(e)}\n`);
83+
});
84+
7985
let totalUsage: Usage = { ...EMPTY_USAGE, cost: { ...EMPTY_USAGE.cost } };
8086
let status: SessionState["status"] = "idle";
8187
let pendingPermission: PendingPermission | undefined;
@@ -180,6 +186,7 @@ export async function runAppServer(opts: AppServerOptions = {}): Promise<number>
180186

181187
unsubscribeAgent();
182188
unsubscribePerms();
189+
bundle.mcp.dispose();
183190
return 0;
184191

185192
// ─── command dispatch ──────────────────────────────────────────────

src/commands/builtins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { copy } from "./copy.js";
44
import { cost } from "./cost.js";
55
import { context, debug, help, pwd, whoami } from "./info.js";
66
import { init } from "./init.js";
7+
import { mcp } from "./mcp.js";
78
import { memory } from "./memory.js";
89
import { modelCmd, modelsCmd } from "./model.js";
910
import { outputStyleCmd } from "./output-style.js";
@@ -27,6 +28,7 @@ export const BUILTIN_COMMANDS: readonly Command[] = [
2728
commit,
2829
review,
2930
memory,
31+
mcp,
3032
context,
3133
login,
3234
logout,

src/commands/builtins/mcp.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { homedir } from "node:os";
2+
import { join } from "node:path";
3+
import type { Command } from "../types.js";
4+
5+
/**
6+
* /mcp — show connected MCP servers and their tool counts, or guidance
7+
* on configuring them when none are set up. MCP servers extend the
8+
* agent with external tools (filesystem, Postgres, git, fetch, …)
9+
* without us writing each integration.
10+
*/
11+
export const mcp: Command = {
12+
name: "mcp",
13+
description: "Show connected MCP servers and their tools.",
14+
handler: (_args, ctx) => {
15+
const statuses = ctx.bundle.mcp.status();
16+
if (statuses.length === 0) {
17+
ctx.emit("No MCP servers configured.");
18+
ctx.emit(
19+
`Add stdio servers in ${join(homedir(), ".codebase", "mcp.json")} (or <project>/.codebase/mcp.json):`,
20+
);
21+
ctx.emit(" {");
22+
ctx.emit(' "mcpServers": {');
23+
ctx.emit(' "filesystem": {');
24+
ctx.emit(' "command": "npx",');
25+
ctx.emit(' "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]');
26+
ctx.emit(" }");
27+
ctx.emit(" }");
28+
ctx.emit(" }");
29+
ctx.emit("Restart codebase to connect. (Remote HTTP/SSE servers: coming soon — stdio only for now.)");
30+
return { handled: true };
31+
}
32+
33+
ctx.emit("MCP servers:");
34+
for (const s of statuses) {
35+
if (s.connected) {
36+
ctx.emit(` ✓ ${s.name}${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`);
37+
} else {
38+
ctx.emit(` ✗ ${s.name} — failed: ${s.error ?? "unknown"}`);
39+
}
40+
}
41+
const tools = ctx.bundle.mcp.tools();
42+
if (tools.length > 0) {
43+
ctx.emit("Tools:");
44+
for (const t of tools) ctx.emit(` ${t.name}`);
45+
}
46+
return { handled: true };
47+
},
48+
};

src/headless/run.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export async function runHeadless(opts: HeadlessOptions): Promise<number> {
102102
let errorMessage: string | undefined;
103103
let totalUsage: Usage = { ...EMPTY_USAGE, cost: { ...EMPTY_USAGE.cost } };
104104

105+
// Connect configured MCP servers so headless runs (CI, scripts) can
106+
// use MCP tools too. Best-effort: a failed server is skipped, never
107+
// blocks the run. Subprocesses are torn down in the finally below.
108+
await bundle.connectMcp().catch(() => undefined);
109+
105110
// Always tap the event stream for usage accumulation, regardless of
106111
// output format — pi-agent-core surfaces per-turn usage on
107112
// message_end events; the JSON output reports the running total.
@@ -167,6 +172,9 @@ export async function runHeadless(opts: HeadlessOptions): Promise<number> {
167172
unsubscribe();
168173
usageUnsub();
169174
process.off("SIGINT", onSigInt);
175+
// Terminate MCP server subprocesses so a headless run doesn't leak
176+
// children to the parent shell / CI runner.
177+
bundle.mcp.dispose();
170178
}
171179

172180
const exitCode = aborted ? 130 : errored ? 1 : 0;

src/mcp/__test__/mock-server.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env node
2+
// Minimal MCP stdio server for tests. Speaks newline-delimited JSON-RPC 2.0:
3+
// handles initialize, tools/list, tools/call. One tool, "echo", that returns
4+
// its `text` argument. Exits when stdin closes.
5+
import { createInterface } from "node:readline";
6+
7+
const rl = createInterface({ input: process.stdin });
8+
9+
function send(obj) {
10+
process.stdout.write(`${JSON.stringify(obj)}\n`);
11+
}
12+
13+
rl.on("line", (line) => {
14+
let msg;
15+
try {
16+
msg = JSON.parse(line);
17+
} catch {
18+
return;
19+
}
20+
if (msg.method === "initialize") {
21+
send({
22+
jsonrpc: "2.0",
23+
id: msg.id,
24+
result: {
25+
protocolVersion: "2025-06-18",
26+
capabilities: { tools: {} },
27+
serverInfo: { name: "mock", version: "0" },
28+
},
29+
});
30+
return;
31+
}
32+
if (msg.method === "notifications/initialized") return; // no response
33+
if (msg.method === "tools/list") {
34+
send({
35+
jsonrpc: "2.0",
36+
id: msg.id,
37+
result: {
38+
tools: [
39+
{
40+
name: "echo",
41+
description: "Echo back the text argument.",
42+
inputSchema: {
43+
type: "object",
44+
properties: { text: { type: "string" } },
45+
required: ["text"],
46+
},
47+
},
48+
],
49+
},
50+
});
51+
return;
52+
}
53+
if (msg.method === "tools/call") {
54+
const { name, arguments: args } = msg.params ?? {};
55+
if (name === "echo") {
56+
send({
57+
jsonrpc: "2.0",
58+
id: msg.id,
59+
result: { content: [{ type: "text", text: String(args?.text ?? "") }] },
60+
});
61+
} else {
62+
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unknown tool: ${name}` } });
63+
}
64+
return;
65+
}
66+
// Unknown method → error response.
67+
if (typeof msg.id === "number") {
68+
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unknown method: ${msg.method}` } });
69+
}
70+
});
71+
72+
rl.on("close", () => process.exit(0));

src/mcp/config.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { loadMcpServers } from "./config.js";
6+
7+
describe("loadMcpServers", () => {
8+
let home: string;
9+
let cwd: string;
10+
11+
beforeEach(() => {
12+
home = mkdtempSync(join(tmpdir(), "mcp-home-"));
13+
cwd = mkdtempSync(join(tmpdir(), "mcp-cwd-"));
14+
});
15+
afterEach(() => {
16+
rmSync(home, { recursive: true, force: true });
17+
rmSync(cwd, { recursive: true, force: true });
18+
});
19+
20+
function write(root: string, obj: unknown): void {
21+
mkdirSync(join(root, ".codebase"), { recursive: true });
22+
writeFileSync(join(root, ".codebase", "mcp.json"), JSON.stringify(obj), "utf8");
23+
}
24+
25+
it("returns [] when no config exists", () => {
26+
expect(loadMcpServers({ home, cwd })).toEqual([]);
27+
});
28+
29+
it("parses a stdio server with args + env", () => {
30+
write(home, {
31+
mcpServers: {
32+
postgres: { command: "uvx", args: ["mcp-server-postgres"], env: { DATABASE_URL: "postgres://x" } },
33+
},
34+
});
35+
const servers = loadMcpServers({ home, cwd });
36+
expect(servers).toHaveLength(1);
37+
expect(servers[0].name).toBe("postgres");
38+
expect(servers[0].spec).toMatchObject({
39+
command: "uvx",
40+
args: ["mcp-server-postgres"],
41+
env: { DATABASE_URL: "postgres://x" },
42+
});
43+
});
44+
45+
it("project config overrides user config on name collision", () => {
46+
write(home, { mcpServers: { fs: { command: "user-cmd" } } });
47+
write(cwd, { mcpServers: { fs: { command: "project-cmd" } } });
48+
const servers = loadMcpServers({ home, cwd });
49+
expect(servers).toHaveLength(1);
50+
expect(servers[0].spec.command).toBe("project-cmd");
51+
});
52+
53+
it("skips remote (url) servers — stdio only in v1", () => {
54+
write(home, {
55+
mcpServers: {
56+
remote: { url: "https://mcp.example.com" },
57+
local: { command: "local-cmd" },
58+
},
59+
});
60+
const servers = loadMcpServers({ home, cwd });
61+
expect(servers.map((s) => s.name)).toEqual(["local"]);
62+
});
63+
64+
it("skips entries missing a command", () => {
65+
write(home, { mcpServers: { broken: { args: ["x"] } } });
66+
expect(loadMcpServers({ home, cwd })).toEqual([]);
67+
});
68+
69+
it("tolerates invalid JSON without throwing", () => {
70+
mkdirSync(join(home, ".codebase"), { recursive: true });
71+
writeFileSync(join(home, ".codebase", "mcp.json"), "{ not json", "utf8");
72+
expect(loadMcpServers({ home, cwd })).toEqual([]);
73+
});
74+
});

0 commit comments

Comments
 (0)