mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
A manager on the p2 server owns every MCP connection and process. Session workers hold one view each and lease the locations their conversations work at; the manager runs the owner's mcp.json servers there (stdio per session and location, or shared; HTTP shared by default) and offers their tools as deferred Durable tools found through tool_search. Stdio servers on an environment start through its pi-env daemon, one connection per environment shared by its instances, through cmd.exe on Windows. A project's .pi/mcp.json, read by the worker at each location, may add stdio servers that run there and switch the owner's servers on or off; the manager refuses everything else. A lease gets servers only on environments its session works on.
114 lines
4.4 KiB
TypeScript
114 lines
4.4 KiB
TypeScript
// MCP tools as Durable tools: one deferred tool (found through `tool_search`) per tool of the session's servers, named
|
|
// as pi names them (`mcp__<server>__<tool>`), calling the server's instance where the calling conversation works.
|
|
|
|
import type { JsonValue } from "@earendil-works/chord/json";
|
|
import {
|
|
type ConversationId,
|
|
type DocumentReader,
|
|
defineExtension,
|
|
type Extension,
|
|
type ToolRegistration,
|
|
} from "@earendil-works/pi-durable";
|
|
import {
|
|
type CallToolResult,
|
|
createMcpToolName,
|
|
limitMcpContent,
|
|
normalizeMcpInputSchema,
|
|
toLlmContent,
|
|
} from "@earendil-works/pi-mcp";
|
|
import type { Context } from "@p2/app/v1";
|
|
import { type Instance, MCP_EXTENSION, type McpTool } from "./contract.ts";
|
|
|
|
/** One tool of one server. */
|
|
export type ServerTool = { readonly server: string; readonly tool: McpTool };
|
|
|
|
/** A call of `server`'s `tool` for conversation `conversationId` (its documents read through `read`). */
|
|
export type McpCall = (
|
|
request: {
|
|
readonly conversationId: ConversationId;
|
|
readonly read: DocumentReader;
|
|
readonly server: string;
|
|
readonly tool: string;
|
|
readonly args: Record<string, unknown>;
|
|
},
|
|
context: Context,
|
|
) => Promise<CallToolResult>;
|
|
|
|
/** The tools of `servers` by server and tool name; a server with instances at several locations lists a tool once. */
|
|
export function serverTools(servers: readonly Instance[]): ServerTool[] {
|
|
const tools = new Map<string, ServerTool>();
|
|
for (const instance of servers)
|
|
for (const tool of instance.tools) {
|
|
const key = JSON.stringify([instance.server, tool.name]);
|
|
if (!tools.has(key)) tools.set(key, { server: instance.server, tool });
|
|
}
|
|
const order = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
return [...tools.values()].sort((a, b) => order(a.server, b.server) || order(a.tool.name, b.tool.name));
|
|
}
|
|
|
|
/**
|
|
* The Durable extension offering `tools`, each calling through `call`. As in pi (and Codex), every tool whose name
|
|
* sanitizes to one another tool's also gets pi's hash suffix, so no tool's name depends on the order of the list
|
|
* and a new tool renames only those it collides with.
|
|
*/
|
|
export function createMcpExtension(tools: readonly ServerTool[], call: McpCall): Extension {
|
|
const plain = new Map<string, number>();
|
|
for (const { server, tool } of tools) {
|
|
const name = createMcpToolName(server, tool.name);
|
|
plain.set(name, (plain.get(name) ?? 0) + 1);
|
|
}
|
|
const assigned = new Set<string>();
|
|
return defineExtension({
|
|
name: MCP_EXTENSION,
|
|
tools: tools.map((entry) => {
|
|
const name = createMcpToolName(
|
|
entry.server,
|
|
entry.tool.name,
|
|
(candidate) => (plain.get(candidate) ?? 0) > 1 || assigned.has(candidate),
|
|
);
|
|
assigned.add(name);
|
|
return toolRegistration(name, entry, call);
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** One MCP tool as a deferred Durable tool (not replay-safe: an interrupted call is not repeated). */
|
|
function toolRegistration(name: string, { server, tool }: ServerTool, call: McpCall): ToolRegistration {
|
|
const title = tool.title ?? tool.annotations?.title;
|
|
return {
|
|
name,
|
|
description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`,
|
|
parameters: normalizeMcpInputSchema(tool.inputSchema),
|
|
deferred: true,
|
|
...(tool.outputSchema === undefined ? {} : { structuredOutputSchema: tool.outputSchema }),
|
|
async execute(args, api, context) {
|
|
// Durable checked `args` against `parameters`, an object schema; its type cannot follow a runtime schema.
|
|
const result = await call(
|
|
{
|
|
conversationId: api.conversationId,
|
|
read: api,
|
|
server,
|
|
tool: tool.name,
|
|
args: args as Record<string, unknown>,
|
|
},
|
|
context,
|
|
);
|
|
const content = toLlmContent(result);
|
|
if (result.isError && !content.some((block) => block.type === "text" && block.text.trim() !== ""))
|
|
content.push({ type: "text", text: `MCP tool ${server}/${tool.name} returned an error` });
|
|
const limited = await limitMcpContent(content);
|
|
// Structured output only for a tool that declares its schema (Durable checks it), error results too. The
|
|
// server's JSON arrived as JSON; the type says `unknown` only because MCP allows any value.
|
|
const structured =
|
|
tool.outputSchema !== undefined && result.structuredContent !== undefined
|
|
? { structuredOutput: result.structuredContent as JsonValue }
|
|
: {};
|
|
return {
|
|
output: limited.content,
|
|
isError: result.isError === true,
|
|
details: { server, tool: tool.name },
|
|
...structured,
|
|
};
|
|
},
|
|
};
|
|
}
|