@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
257 lines • 12.6 kB
JavaScript
/**
* Adapts MCP tools to pi tool definitions.
*
* Results map onto pi's model-facing content (text and images). Text over 20KB keeps its start and
* end with the middle cut out, like Codex does, and the full text is saved to a temp file the model
* can read. Binary resources other than images are saved to temp files too, and resource links name
* the `read_mcp_resource` tool. Codemode scripts receive the whole `CallToolResult` without `_meta`
* (`content` blocks as sent by the server, `structuredContent`, `isError`), never truncated: it is
* the tool's `structuredContent`, and every MCP tool declares a `CallToolResult` output schema. MCP
* errors (`isError`) are error results for the model, but scripts still resolve to the result.
*/
import { limitMcpContent, normalizeMcpInputSchema, toLlmContent, } from "@earendil-works/pi-mcp";
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
import { READ_MCP_RESOURCE_TOOL } from "../../core/mcp-servers.js";
import { formatToolCallWithArgs, getTextOutput, replaceTabs } from "../../core/tools/render-utils.js";
import { formatSize } from "../../core/tools/truncate.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { VisualLinePreview } from "../../modes/interactive/components/visual-truncate.js";
import { writeOutputFile } from "../../utils/output-files.js";
/**
* Tool exposure of an MCP exposure. `codemode` and `deferred` both leave tools out of the codemode
* description; they differ only in which tool the MCP extension activates to reach them.
*/
export function toToolExposure(exposure) {
return exposure === "codemode" ? "deferred" : exposure;
}
/** Visual (wrapped) result lines shown before the output is expanded. */
const OUTPUT_PREVIEW_LINES = 5;
/** Tool that reads the resources named by resource links. */
export { READ_MCP_RESOURCE_TOOL };
/** Saves the full text of a truncated result, or a binary resource, to a temp file and returns its path. */
export function saveToTempFile(data, extension) {
return writeOutputFile("pi-mcp", extension, data);
}
function textOf(content) {
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}
/**
* MCP content blocks as one compact type with the fields of text and image blocks, which scripts
* read most (`image(block)` takes an image block as is). Rendered into every MCP tool's declaration.
*/
const CONTENT_BLOCK_SCHEMA = {
type: "object",
properties: {
type: { enum: ["text", "image", "audio", "resource_link", "resource"] },
text: { type: "string" },
data: { type: "string" },
mimeType: { type: "string" },
},
required: ["type"],
additionalProperties: true,
};
/** Keywords whose values are data, so a `$ref` key in them is not a reference. */
const SCHEMA_DATA_KEYWORDS = new Set(["const", "enum", "default", "examples"]);
/** Keywords whose values map names to schemas, so their keys are names, not keywords. */
const SCHEMA_MAP_KEYWORDS = new Set([
"properties",
"patternProperties",
"dependentSchemas",
"dependencies",
"$defs",
"definitions",
]);
/**
* `schema` as a subschema at the JSON pointer `pointer` (`#/properties/structuredContent`) of
* another schema: its local references (`#`, `#/$defs/Item`) are rewritten to point below
* `pointer`, so they resolve to the same subschemas as before.
*/
function relocateSchema(schema, pointer) {
if (Array.isArray(schema))
return schema.map((item) => relocateSchema(item, pointer));
if (typeof schema !== "object" || schema === null)
return schema;
const relocated = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "$ref" && typeof value === "string" && (value === "#" || value.startsWith("#/"))) {
relocated[key] = `${pointer}${value.slice(1)}`;
}
else if (SCHEMA_DATA_KEYWORDS.has(key)) {
relocated[key] = value;
}
else if (SCHEMA_MAP_KEYWORDS.has(key) && typeof value === "object" && value !== null && !Array.isArray(value)) {
relocated[key] = Object.fromEntries(Object.entries(value).map(([name, subschema]) => [name, relocateSchema(subschema, pointer)]));
}
else {
relocated[key] = relocateSchema(value, pointer);
}
}
return relocated;
}
/**
* Output schema of every MCP tool: the `CallToolResult` scripts receive (without `_meta`, which
* {@link convertMcpResult} drops), with the tool's own output schema as `structuredContent`.
*/
export function createMcpResultSchema(structuredContentSchema) {
return {
type: "object",
properties: {
content: { type: "array", items: CONTENT_BLOCK_SCHEMA },
// Servers may send structured content without declaring a schema for it, and it may be any JSON value.
structuredContent: structuredContentSchema
? relocateSchema(structuredContentSchema, "#/properties/structuredContent")
: {},
isError: { type: "boolean" },
},
required: ["content"],
// Fields newer protocol versions add.
additionalProperties: true,
};
}
/** File extension for a saved binary resource: the one its URI ends in, else `.bin`. */
function extensionOf(uri) {
const path = URL.canParse(uri) ? new URL(uri).pathname : uri;
return /\.[A-Za-z0-9]{1,8}$/.exec(path)?.[0] ?? ".bin";
}
/** Blobs of these types are shown as text. */
function isTextMimeType(mimeType) {
if (!mimeType)
return false;
const type = mimeType.split(";", 1)[0].trim().toLowerCase();
return type.startsWith("text/") || type === "application/json" || type.endsWith("+json") || type.endsWith("+xml");
}
/** Model-facing content of one block of `server`'s result. */
async function blockToContent(server, block, options) {
if (block.type === "resource_link") {
const details = [block.mimeType, block.size === undefined ? undefined : formatSize(block.size)].filter(Boolean);
const read = options.readableResources ? `. Read it with ${READ_MCP_RESOURCE_TOOL} (server "${server}")` : "";
const description = block.description ? `: ${block.description}` : "";
return [
{
type: "text",
text: `[Resource ${block.uri} "${block.title ?? block.name}"${details.length > 0 ? ` (${details.join(", ")})` : ""}${description}${read}]`,
},
];
}
if (block.type === "resource" && "blob" in block.resource && !block.resource.mimeType?.startsWith("image/")) {
const { uri, mimeType, blob } = block.resource;
const data = Buffer.from(blob, "base64");
if (isTextMimeType(mimeType))
return [{ type: "text", text: data.toString("utf8") }];
const kind = `${mimeType ?? "unknown type"}, ${formatSize(data.length)}`;
try {
const path = await (options.saveOutput ?? saveToTempFile)(data, extensionOf(uri));
return [{ type: "text", text: `[Binary resource ${uri} (${kind}) saved to ${path}]` }];
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return [{ type: "text", text: `[Binary resource ${uri} (${kind}) could not be saved: ${reason}]` }];
}
}
return toLlmContent({ content: [block] });
}
/** Model-facing content of `server`'s content blocks, before the output limit. */
export async function toModelContent(server, blocks, options = {}) {
return (await Promise.all(blocks.map((block) => blockToContent(server, block, options)))).flat();
}
/** Convert an MCP result. `isError` results become error results that keep the structured result. */
export async function convertMcpResult(server, tool, result, options = {}) {
// Without content blocks, toLlmContent falls back to the structured content as JSON.
const converted = result.content.length > 0 ? await toModelContent(server, result.content, options) : toLlmContent(result);
if (result.isError && textOf(converted) === "") {
converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` });
}
const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput ?? saveToTempFile);
const { _meta: _ignored, ...scriptResult } = result;
return {
content,
details: { server, tool, ...(fullOutputPath ? { fullOutputPath } : {}) },
structuredContent: scriptResult,
...(result.isError ? { isError: true } : {}),
};
}
const ANNOTATION_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"];
/** The boolean hints of an MCP tool's annotations, or undefined when it has none. */
function toToolAnnotations(tool) {
const annotations = {};
for (const hint of ANNOTATION_HINTS) {
const value = tool.annotations?.[hint];
if (typeof value === "boolean")
annotations[hint] = value;
}
return Object.keys(annotations).length > 0 ? annotations : undefined;
}
export function createMcpToolDefinition(options) {
const { server, tool } = options;
const title = tool.title ?? tool.annotations?.title;
const annotations = toToolAnnotations(tool);
const label = `${server}/${tool.name}`;
return {
name: options.name,
label,
description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`,
// An object schema, which is what TypeBox schemas are at runtime.
parameters: normalizeMcpInputSchema(tool.inputSchema),
outputSchema: createMcpResultSchema(tool.outputSchema),
exposure: toToolExposure(options.exposure),
namespace: options.namespace,
...(annotations ? { annotations } : {}),
...createMcpToolRenderers(label),
async execute(_toolCallId, params, signal, onUpdate) {
const client = await options.getClient();
const result = await client.callTool(tool.name, (params ?? {}), {
signal,
timeoutMs: options.timeoutMs,
onProgress: (progress) => {
const total = progress.total === undefined ? "" : `/${progress.total}`;
const text = progress.message ?? `Progress ${progress.progress}${total}`;
onUpdate?.({ content: [{ type: "text", text }], details: { server, tool: tool.name } });
},
});
return convertMcpResult(server, tool.name, result, { readableResources: options.readableResources?.() });
},
};
}
/** Renderers of calls to an MCP tool, labeled `server/tool`, also used before the tool is registered. */
export function createMcpToolRenderers(label) {
return {
renderCall(args, theme, context) {
const component = context.lastComponent ?? new Text("", 0, 0);
component.setText(formatToolCallWithArgs(label, args, theme, context.expanded));
return component;
},
renderResult(result, options, theme, context) {
const component = context.lastComponent ?? new Container();
component.clear();
const output = getTextOutput(result, context.showImages).trim();
if (!output)
return component;
const color = context.isError ? "error" : "toolOutput";
const styled = replaceTabs(output)
.split("\n")
.map((line) => theme.fg(color, line))
.join("\n");
component.addChild(new Spacer(1));
if (options.expanded) {
component.addChild(new Text(styled, 0, 0));
}
else {
// Limit wrapped lines, not logical ones: MCP results are often one long JSON line.
component.addChild(new VisualLinePreview({
text: styled,
maxVisualLines: OUTPUT_PREVIEW_LINES,
keep: "start",
formatHint: (hidden) => `${theme.fg("muted", `... (${hidden} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
}));
const fullOutputPath = result.details?.fullOutputPath;
if (fullOutputPath)
component.addChild(new Text(theme.fg("muted", `Full output: ${fullOutputPath}`), 0, 0));
}
return component;
},
};
}
//# sourceMappingURL=tools.js.map