Extract generic stdio MCP bridge with optional tool-name prefix
This commit is contained in:
72
src/agent/mcp-bridge.test.ts
Normal file
72
src/agent/mcp-bridge.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLogger } from "../log.js";
|
||||
import { createMcpBridge, type McpClientLike, type SpawnSpec } from "./mcp-bridge.js";
|
||||
|
||||
const silentLogger = createLogger("error", () => {});
|
||||
|
||||
function fakeClient(overrides: Partial<McpClientLike> = {}) {
|
||||
const state = { closed: 0, calls: [] as Array<{ name: string; arguments: Record<string, unknown> }> };
|
||||
const client: McpClientLike = {
|
||||
listTools: async () => ({
|
||||
tools: [
|
||||
{ name: "navigate", description: "Navigate to a URL", inputSchema: { type: "object", properties: { url: { type: "string" } } } },
|
||||
{ name: "snapshot", description: "Capture snapshot", inputSchema: { type: "object", properties: {} } },
|
||||
],
|
||||
}),
|
||||
callTool: async (req) => {
|
||||
state.calls.push(req);
|
||||
return { content: [{ type: "text", text: `called ${req.name}` }] };
|
||||
},
|
||||
close: async () => { state.closed += 1; },
|
||||
...overrides,
|
||||
};
|
||||
return { client, state };
|
||||
}
|
||||
|
||||
const base = { label: "test", command: "cmd", args: [] as string[], logger: silentLogger };
|
||||
|
||||
describe("createMcpBridge", () => {
|
||||
it("wraps every MCP tool with matching name and description when no prefix is given", async () => {
|
||||
const { client } = fakeClient();
|
||||
const bridge = await createMcpBridge({ ...base, clientFactory: async () => client });
|
||||
expect(bridge.tools.map((t) => t.name)).toEqual(["navigate", "snapshot"]);
|
||||
expect(bridge.tools[0].description).toBe("Navigate to a URL");
|
||||
});
|
||||
|
||||
it("prefixes Pi tool names but calls the MCP tool by its original name", async () => {
|
||||
const { client, state } = fakeClient();
|
||||
const bridge = await createMcpBridge({ ...base, toolPrefix: "fortress_", clientFactory: async () => client });
|
||||
expect(bridge.tools.map((t) => t.name)).toEqual(["fortress_navigate", "fortress_snapshot"]);
|
||||
const result = await bridge.tools[0].execute("c1", { url: "https://x" }, undefined, undefined, {} as never);
|
||||
expect(state.calls).toEqual([{ name: "navigate", arguments: { url: "https://x" } }]);
|
||||
expect(result.content).toEqual([{ type: "text", text: "called navigate" }]);
|
||||
});
|
||||
|
||||
it("passes command, args, and env to the client factory", async () => {
|
||||
const { client } = fakeClient();
|
||||
const seen: SpawnSpec[] = [];
|
||||
await createMcpBridge({
|
||||
...base,
|
||||
command: "tilion-mcp",
|
||||
args: ["--x"],
|
||||
env: { A: "1" },
|
||||
clientFactory: async (spec) => { seen.push(spec); return client; },
|
||||
});
|
||||
expect(seen).toEqual([{ command: "tilion-mcp", args: ["--x"], env: { A: "1" } }]);
|
||||
});
|
||||
|
||||
it("dispose closes the client", async () => {
|
||||
const { client, state } = fakeClient();
|
||||
const bridge = await createMcpBridge({ ...base, clientFactory: async () => client });
|
||||
await bridge.dispose();
|
||||
expect(state.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("dispose swallows close errors and logs a labelled warning", async () => {
|
||||
const { client } = fakeClient({ close: async () => { throw new Error("boom"); } });
|
||||
const warn = vi.fn();
|
||||
const bridge = await createMcpBridge({ ...base, label: "fortress", logger: { ...silentLogger, warn }, clientFactory: async () => client });
|
||||
await expect(bridge.dispose()).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fortress mcp close failed"), expect.any(Error));
|
||||
});
|
||||
});
|
||||
96
src/agent/mcp-bridge.ts
Normal file
96
src/agent/mcp-bridge.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import type { Logger } from "../log.js";
|
||||
import type { CustomTools } from "./pi-backend.js";
|
||||
|
||||
export interface SpawnSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface McpBridgeOptions extends SpawnSpec {
|
||||
// Used in log lines: "<label> mcp: ...".
|
||||
label: string;
|
||||
// Prepended to every Pi tool name; the MCP server still sees the original name.
|
||||
toolPrefix?: string;
|
||||
logger: Logger;
|
||||
// Test seam: inject an already-connected MCP client. When omitted, the
|
||||
// bridge spawns `command` as a stdio subprocess.
|
||||
clientFactory?: (spec: SpawnSpec) => Promise<McpClientLike>;
|
||||
}
|
||||
|
||||
export interface McpBridge {
|
||||
tools: CustomTools;
|
||||
dispose: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Minimal shape of the MCP Client we depend on. Kept narrow so tests can
|
||||
// implement it in a few lines.
|
||||
export interface McpClientLike {
|
||||
listTools: () => Promise<{ tools: McpToolDescriptor[] }>;
|
||||
callTool: (req: { name: string; arguments: Record<string, unknown> }) => Promise<McpCallResult>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface McpToolDescriptor {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
annotations?: { title?: string };
|
||||
}
|
||||
|
||||
export interface McpCallResult {
|
||||
content: Array<Record<string, unknown>>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export async function createMcpBridge(opts: McpBridgeOptions): Promise<McpBridge> {
|
||||
const spec: SpawnSpec = { command: opts.command, args: opts.args, env: opts.env };
|
||||
const factory = opts.clientFactory ?? ((s) => defaultClientFactory(s, opts.label, opts.logger));
|
||||
const client = await factory(spec);
|
||||
const { tools: mcpTools } = await client.listTools();
|
||||
const tools: CustomTools = mcpTools.map((t) => wrapTool(t, client, opts.toolPrefix ?? ""));
|
||||
return {
|
||||
tools,
|
||||
dispose: async () => {
|
||||
try {
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
opts.logger.warn(`${opts.label} mcp close failed`, err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTool(t: McpToolDescriptor, client: McpClientLike, prefix: string) {
|
||||
return defineTool({
|
||||
name: prefix + t.name,
|
||||
label: t.annotations?.title ?? t.name,
|
||||
description: t.description ?? "",
|
||||
parameters: Type.Unsafe<Record<string, unknown>>(t.inputSchema ?? { type: "object" }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const result = await client.callTool({ name: t.name, arguments: params as Record<string, unknown> });
|
||||
// MCP callTool returns { content, isError? }; Pi tools return { content, details }.
|
||||
// MCP's content shape (type: "text" | "image" | "resource"; text/data/uri) is compatible
|
||||
// with what Pi passes through to the model.
|
||||
return { content: result.content as never, details: { isError: result.isError ?? false } };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultClientFactory(spec: SpawnSpec, label: string, logger: Logger): Promise<McpClientLike> {
|
||||
const env = spec.env
|
||||
? { ...(Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)) as Record<string, string>), ...spec.env }
|
||||
: undefined;
|
||||
const transport = new StdioClientTransport({ command: spec.command, args: spec.args, env, stderr: "pipe" });
|
||||
const client = new Client({ name: "volcanic-secretary", version: "0.1.0" });
|
||||
await client.connect(transport);
|
||||
transport.stderr?.on("data", (buf: Buffer | string) => {
|
||||
const line = (typeof buf === "string" ? buf : buf.toString("utf8")).trimEnd();
|
||||
if (line) logger.warn(`${label} mcp: ${line}`);
|
||||
});
|
||||
return client as unknown as McpClientLike;
|
||||
}
|
||||
Reference in New Issue
Block a user