fix(coding-agent): render MCP tool calls before their server connects

Add pi.registerToolRenderer((toolName, next) => renderers), which chooses the
renderers for calls to any tool, including tools that are not registered.
Resolvers run in extension load order; next() returns what the remaining
resolvers, then the registered tool, would use. Interactive mode and HTML
export both resolve through it.

The MCP extension uses it to render every mcp__<server>__<tool> call with the
MCP renderers, so calls in a resumed session no longer render fully expanded
before their server connects, or for good if it never does.

closes #10285
This commit is contained in:
Armin Ronacher 2026-10-03 13:53:50 +02:00
parent a276dabe57
commit 11449730c8
17 changed files with 189 additions and 47 deletions

View file

@ -8,6 +8,7 @@
- Added `oauth.clientRegistration: "cimd"` for MCP servers, which identifies pi with its Client ID Metadata Document on pi.dev instead of dynamic client registration, so authorization servers can allow pi by URL ([#10302](https://github.com/earendil-works/pi/issues/10302))
- Added project overrides for user-level MCP servers: a `.pi/mcp.json` entry without `command` or `url` sets only `enabled`, `exposure`, and `toolExposure` of the user-level server, and `/mcp` can enable or disable a server for the current project ([#10277](https://github.com/earendil-works/pi/issues/10277))
- Added Cloudflare's Clef and Clef Flash classifier models to `cloudflare-workers-ai`, usable from codemode scripts and extensions ([#10316](https://github.com/earendil-works/pi/pull/10316) by [@ndisidore](https://github.com/ndisidore), [#10322](https://github.com/earendil-works/pi/pull/10322) by [@RealAlexandreAI](https://github.com/RealAlexandreAI))
- Added `pi.registerToolRenderer()`, which chooses how calls to a tool are drawn, including tools that are not registered ([#10285](https://github.com/earendil-works/pi/issues/10285))
### Changed
@ -23,6 +24,7 @@
- Fixed a trailing comma in `--models` adding an extra model to the model cycle ([#10334](https://github.com/earendil-works/pi/issues/10334))
- Fixed a `codemode` script that prints in a loop crashing pi by running out of memory: a script fails once its output passes 16 Mi characters or 100000 items ([#10283](https://github.com/earendil-works/pi/issues/10283))
- Fixed JPEG, GIF, and WebP images rendered by extensions through `Image` not appearing in Kitty, Ghostty, WezTerm, and Warp ([#10292](https://github.com/earendil-works/pi/issues/10292))
- Fixed MCP tool calls in resumed sessions and HTML exports rendering fully expanded until their server connected, or for good if it never did ([#10285](https://github.com/earendil-works/pi/issues/10285))
## [1.0.0] - 2026-10-01

View file

@ -185,6 +185,10 @@ Register every tool first, keep optional tools inactive, and use `pi.setActiveTo
Pi records the initial prompt and tool set in the transcript's first system message, then appends tool and prompt changes before the next model request. Providers that cannot represent the transition receive a complete transcript checkpoint, which can invalidate the cached prefix.
### Tool rendering
A tool's `renderCall` and `renderResult` draw its calls in the interactive transcript and in HTML exports. `pi.registerToolRenderer((toolName, next) => renderers)` chooses renderers for calls to any tool, including tools that are not registered yet, such as MCP tools in a resumed session before their server connected. `next()` returns what the remaining resolvers (in extension load order), then the registered tool, would use, so `next() ?? mine` only fills in.
### MCP servers
`pi.registerMcpServer(name, config)` adds an MCP server for the current session. `config` has the shape of an `mcpServers` entry in [`mcp.json`](mcp.md): `command`, `args`, `env`, and `cwd` for stdio servers, `url`, `headers`, and `oauth` for HTTP servers, plus `exposure`, `toolExposure`, `description`, `enabled`, and `timeout`.

View file

@ -4243,7 +4243,8 @@ export class AgentSession {
// Create tool renderer if we have an extension runner (for custom tool HTML rendering)
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
getToolDefinition: (name) => this.getToolDefinition(name),
getToolRenderers: (name) =>
this._extensionRunner.resolveToolRenderers(name, () => this.getToolDefinition(name)),
theme,
cwd: this.sessionManager.getCwd(),
});

View file

@ -8,12 +8,12 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.ts";
import type { ToolRenderContext, ToolRenderers } from "../extensions/types.ts";
import { ansiLinesToHtml } from "./ansi-to-html.ts";
export interface ToolHtmlRendererDeps {
/** Function to look up tool definition by name */
getToolDefinition: (name: string) => ToolDefinition | undefined;
/** Renderers of calls to a tool, as resolved by extensions and the registered tool */
getToolRenderers: (name: string) => ToolRenderers | undefined;
/** Theme for styling */
theme: Theme;
/** Working directory for render context */
@ -56,7 +56,7 @@ function trimRenderedResultLines(lines: string[]): string[] {
}
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
const { getToolDefinition, theme, cwd, width = 100 } = deps;
const { getToolRenderers, theme, cwd, width = 100 } = deps;
const renderedCallComponents = new Map<string, Component>();
const renderedResultComponents = new Map<string, Component>();
@ -99,7 +99,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined {
try {
renderedArgs.set(toolCallId, args);
const toolDef = getToolDefinition(toolName);
const toolDef = getToolRenderers(toolName);
if (!toolDef?.renderCall) {
return undefined;
}
@ -126,7 +126,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
isError: boolean,
): { collapsed?: string; expanded?: string } | undefined {
try {
const toolDef = getToolDefinition(toolName);
const toolDef = getToolRenderers(toolName);
if (!toolDef?.renderResult) {
return undefined;
}

View file

@ -187,6 +187,8 @@ export type {
ToolLoadout,
ToolLoadoutChanges,
ToolNamespace,
ToolRendererResolver,
ToolRenderers,
ToolRenderResultOptions,
ToolResultEvent,
ToolResultEventResult,

View file

@ -33,6 +33,7 @@ import type {
ProviderConfig,
RegisteredCommand,
ToolDefinition,
ToolRendererResolver,
} from "./types.ts";
const require = createRequire(import.meta.url);
@ -363,6 +364,12 @@ function createExtensionAPI(
extension.entryRenderers.set(customType, renderer as EntryRenderer);
},
registerToolRenderer(resolver: ToolRendererResolver): void {
assertActive();
extension.toolRenderers ??= [];
extension.toolRenderers.push(resolver);
},
// Flag access - checks extension registered it, reads from runtime
getFlag(name: string): boolean | string | undefined {
assertActive();

View file

@ -82,6 +82,7 @@ import type {
SessionShutdownEvent,
ToolCallEvent,
ToolCallEventResult,
ToolRenderers,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
@ -785,6 +786,14 @@ export class ExtensionRunner {
return this.extensions.flatMap((ext) => (ext.markdownTransformer ? [ext.markdownTransformer] : []));
}
/** Renderers of calls to `toolName`: extension resolvers in load order, then `base`. */
resolveToolRenderers(toolName: string, base: () => ToolRenderers | undefined): ToolRenderers | undefined {
const resolvers = this.extensions.flatMap((ext) => ext.toolRenderers ?? []);
const resolve = (index: number): ToolRenderers | undefined =>
index < resolvers.length ? resolvers[index](toolName, () => resolve(index + 1)) : base();
return resolve(0);
}
getEntryRenderer(customType: string): EntryRenderer | undefined {
for (const ext of this.extensions) {
const renderer = ext.entryRenderers?.get(customType);

View file

@ -646,6 +646,17 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
type AnyToolDefinition = ToolDefinition<any, any, any>;
export type ToolRenderers = Pick<AnyToolDefinition, "renderShell" | "renderCall" | "renderResult">;
/**
* Chooses how calls to a tool are drawn, including tools that are not registered. `next()` returns
* the renderers the remaining resolvers, then the registered tool, would use.
*/
export type ToolRendererResolver = (
toolName: string,
next: () => ToolRenderers | undefined,
) => ToolRenderers | undefined;
/**
* Preserve parameter inference for standalone tool definitions.
*
@ -1668,6 +1679,9 @@ export interface ExtensionAPI {
/** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */
registerEntryRenderer<T = unknown>(customType: string, renderer: EntryRenderer<T>): void;
/** Choose how tool calls are drawn. Resolvers run in extension load order. */
registerToolRenderer(resolver: ToolRendererResolver): void;
// =========================================================================
// Actions
// =========================================================================
@ -2217,6 +2231,7 @@ export interface Extension {
handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>;
toolRenderers?: ToolRendererResolver[];
markdownTransformer?: MarkdownTransformer;
entryRenderers?: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;

View file

@ -6,7 +6,7 @@
* graph out of a process that only renders.
*/
import type { ToolDefinition } from "../../extensions/types.ts";
import type { ToolRenderers } from "../../extensions/types.ts";
import type { ToolName } from "../index.ts";
import { createShellRenderers } from "./bash.ts";
import { editRenderers } from "./edit.ts";
@ -16,7 +16,7 @@ import { lsRenderers } from "./ls.ts";
import { readRenderers } from "./read.ts";
import { writeRenderers } from "./write.ts";
export type ToolRenderers = Pick<ToolDefinition<any, any>, "renderCall" | "renderResult">;
export type { ToolRenderers };
export {
createShellRenderers,

View file

@ -61,7 +61,7 @@ import {
import { loadMcpRuntime } from "./runtime.lazy.ts";
import type * as McpRuntime from "./runtime.ts";
import type { McpServerConnection, McpServerLog, McpTransportFactory } from "./runtime.ts";
import { createMcpToolDefinition, createMcpToolName, type McpToolDetails } from "./tools.ts";
import { createMcpToolDefinition, createMcpToolName, createMcpToolRenderers, type McpToolDetails } from "./tools.ts";
import { type McpMenu, type McpUi, showMcpManager } from "./ui.ts";
export type { McpTransportFactory } from "./runtime.ts";
@ -359,6 +359,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
/** Last definition registered under each tool name, to re-register withdrawn tools as hidden. */
const definitions = new Map<string, ToolDefinition<TSchema, McpToolDetails>>();
// A resumed session renders calls to MCP tools before their server connected, if it ever does.
pi.registerToolRenderer((toolName, next) => {
const match = /^mcp__(.+?)__(.+)$/.exec(toolName);
return next() ?? (match ? createMcpToolRenderers(`${match[1]}/${match[2]}`) : undefined);
});
const registerTools = (connection: McpServerConnection) => {
const server = connection.entry.name;
const entry = findServer(server)?.entry ?? connection.entry;

View file

@ -25,7 +25,13 @@ import {
} from "@earendil-works/pi-mcp";
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
import type { TSchema } from "typebox";
import type { ToolAnnotations, ToolDefinition, ToolExposure, ToolNamespace } from "../../core/extensions/types.ts";
import type {
ToolAnnotations,
ToolDefinition,
ToolExposure,
ToolNamespace,
ToolRenderers,
} from "../../core/extensions/types.ts";
import { formatToolCallWithArgs, getTextOutput, replaceTabs } from "../../core/tools/render-utils.ts";
import { formatSize, truncateMiddle } from "../../core/tools/truncate.ts";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
@ -275,6 +281,26 @@ export function createMcpToolDefinition(options: {
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 ?? {}) as Record<string, unknown>, {
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: string): ToolRenderers {
return {
renderCall(args, theme, context) {
const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
component.setText(formatToolCallWithArgs(label, args, theme, context.expanded));
@ -304,23 +330,10 @@ export function createMcpToolDefinition(options: {
`${theme.fg("muted", `... (${hidden} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
}),
);
const fullOutputPath = result.details?.fullOutputPath;
const fullOutputPath = (result.details as McpToolDetails | undefined)?.fullOutputPath;
if (fullOutputPath) component.addChild(new Text(theme.fg("muted", `Full output: ${fullOutputPath}`), 0, 0));
}
return component;
},
async execute(_toolCallId, params, signal, onUpdate) {
const client = await options.getClient();
const result = await client.callTool(tool.name, (params ?? {}) as Record<string, unknown>, {
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?.() });
},
};
}

View file

@ -179,6 +179,8 @@ export type {
ToolLoadout,
ToolLoadoutChanges,
ToolNamespace,
ToolRendererResolver,
ToolRenderers,
ToolRenderResultOptions,
ToolResultEvent,
ToolResultEventResult,

View file

@ -1,4 +1,3 @@
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import {
Box,
type Component,
@ -11,26 +10,10 @@ import {
type TUI,
type TuiMouseEvent,
} from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext, ToolRenderResultOptions } from "../../../core/extensions/types.ts";
import type { Theme } from "../theme/theme.ts";
import type { ToolDefinition, ToolRenderContext, ToolRenderers } from "../../../core/extensions/types.ts";
/**
* What this component needs from a tool: how to draw it. It neither executes tools nor reads their
* parameter schemas, so a definition and a bare renderer pair are equally acceptable.
*
* The renderer parameters are `any` on purpose: a `ToolDefinition` types them from its schema, and
* narrowing them here would make those definitions unassignable.
*/
export interface ToolRenderers {
renderShell?: "default" | "self";
renderCall?: (args: any, theme: Theme, context: ToolRenderContext<any, any>) => Component;
renderResult?: (
result: AgentToolResult<any>,
options: ToolRenderResultOptions,
theme: Theme,
context: ToolRenderContext<any, any>,
) => Component;
}
/** What this component needs from a tool: how to draw it, without executing it. */
export type { ToolRenderers };
import { formatToolCallWithArgs, getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
import { ensurePngTranscoder } from "../../../utils/image-convert.ts";

View file

@ -2190,7 +2190,9 @@ export class InteractiveMode {
* whatever this returns, so they never reach into the tool registry themselves.
*/
private getRegisteredToolDefinition(toolName: string) {
return withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName));
return this.session.extensionRunner.resolveToolRenderers(toolName, () =>
withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName)),
);
}
private getMarkdownTransformers(): MarkdownTransformer[] {

View file

@ -30,7 +30,7 @@ describe("export HTML tool output whitespace", () => {
renderResult: () => component,
} as unknown as ToolDefinition;
const renderer = createToolHtmlRenderer({
getToolDefinition: () => tool,
getToolRenderers: () => tool,
theme: {} as Theme,
cwd: "/tmp",
});

View file

@ -888,6 +888,30 @@ describe("ExtensionRunner", () => {
});
});
// Issue #10285: the MCP extension renders calls to tools that are not registered.
it("resolves tool renderers in extension load order, each able to defer to the next", async () => {
const runtime = createExtensionRuntime();
const eventBus = createEventBus();
const renderCall = () => ({ render: () => [], invalidate: () => {} });
const first = await loadExtensionFromFactory(
(pi) => pi.registerToolRenderer((toolName, next) => (toolName === "a" ? { renderCall } : next())),
tempDir,
eventBus,
runtime,
);
const second = await loadExtensionFromFactory(
(pi) => pi.registerToolRenderer((_toolName, next) => next() ?? { renderShell: "self" }),
tempDir,
eventBus,
runtime,
);
const runner = new ExtensionRunner([first, second], runtime, tempDir, sessionManager, modelRegistry);
expect(runner.resolveToolRenderers("a", () => undefined)).toEqual({ renderCall });
expect(runner.resolveToolRenderers("b", () => undefined)).toEqual({ renderShell: "self" });
expect(runner.resolveToolRenderers("b", () => ({ renderCall }))).toEqual({ renderCall });
});
describe("boundary chaining", () => {
it("chains shared draft proposals and preserves omitted result fields", async () => {
const runtime = createExtensionRuntime();

View file

@ -0,0 +1,72 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ToolRenderContext } from "../../../src/core/extensions/types.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { createMcpExtension } from "../../../src/extensions/mcp/index.ts";
import { initTheme, theme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness } from "../harness.ts";
const mcpExtension = createMcpExtension({ loadConfig: () => ({ servers: [], errors: [] }) });
describe("MCP tool renderers", () => {
// Regression #10285: a resumed session renders MCP tool calls before their server connected, if it
// ever does. They render with the MCP renderers anyway, instead of the expanded fallback.
it("renders calls to MCP tools that are not registered", async () => {
initTheme("dark");
const harness = await createHarness({ extensionFactories: [mcpExtension] });
try {
const resolve = (toolName: string) =>
harness.session.extensionRunner.resolveToolRenderers(toolName, () =>
harness.session.getToolDefinition(toolName),
);
const call = resolve("mcp__my_docs__search")?.renderCall?.({ query: "pi" }, theme, {
expanded: false,
} as unknown as ToolRenderContext);
expect(stripAnsi(call?.render(100).join("\n") ?? "")).toContain('my_docs/search query="pi"');
expect(resolve("not_mcp")).toBeUndefined();
// Registered tools keep their own renderers.
expect(resolve("read")?.renderCall).toBe(harness.session.getToolDefinition("read")?.renderCall);
} finally {
harness.cleanup();
}
});
it("renders them in HTML exports too", async () => {
initTheme("dark");
const dir = mkdtempSync(join(tmpdir(), "pi-10285-"));
const sessionManager = SessionManager.create(dir, join(dir, "sessions"));
const harness = await createHarness({ extensionFactories: [mcpExtension], sessionManager });
try {
sessionManager.appendMessage({ role: "user", content: "search", timestamp: 1 });
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "mcp__my_docs__search", arguments: { query: "pi" } }],
api: "anthropic-messages",
provider: "anthropic",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
});
const html = readFileSync(await harness.session.exportToHtml(join(dir, "export.html")), "utf8");
const data = /<script id="session-data" type="application\/json">([^<]*)<\/script>/.exec(html)?.[1] ?? "";
const session = JSON.parse(Buffer.from(data, "base64").toString("utf8"));
expect(stripAnsi(session.renderedTools?.["call-1"]?.callHtml ?? "")).toContain("my_docs/search");
} finally {
harness.cleanup();
rmSync(dir, { recursive: true, force: true });
}
});
});