97 lines
3.7 KiB
TypeScript
97 lines
3.7 KiB
TypeScript
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;
|
|
}
|