UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

257 lines • 12.6 kB
/** * 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