refactor(codemode,coding-agent): decouple MCP from codemode

Tools that reach other tools declare it with reachesTools(input); MCP uses it to wait for the servers a call needs and to decide which codemode or tool_search tool to activate, instead of parsing codemode scripts and comparing parameter schemas. Namespaces get aliases, replacing codemode's MCP-specific __ suffix rule.

pi-codemode no longer knows MCP: MCP_TYPESCRIPT_PREAMBLE and mcpStructuredContentSchema() are removed, and the MCP result schema describes itself, with the server's local $refs relocated so they resolve as before.
This commit is contained in:
Armin Ronacher 2026-10-12 09:19:11 +02:00
parent 4270522473
commit 24382bedd0
20 changed files with 377 additions and 314 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Breaking Changes
- Removed `MCP_TYPESCRIPT_PREAMBLE` and `mcpStructuredContentSchema()`. `renderToolOutputType()` and the declarations built on it render every output schema as its own type and no longer turn MCP `CallToolResult` schemas into `CallToolResult<T>`; describe MCP results in the output schema instead
### Added
- Added `RemoteCodemodeSandbox`, which runs each script's VM in another isolate while tools run in the caller. `CodemodeSandboxDurableObject` from the new `@earendil-works/pi-codemode/cloudflare` entry is its remote end, deployed as its own Cloudflare Worker. `examples/cloudflare` shows the setup
@ -12,6 +16,7 @@
### Changed
- `schemaToType()` and the declarations built on it show property constraints the TypeScript type cannot express as tags on the property's comment line, such as `// @integer @minimum 0 @maximum 30000 @default 0`. Covered keywords: `integer`, numeric bounds, `multipleOf`, string length, `pattern`, `format`, array length, `uniqueItems`, non-null `default`, and `deprecated`; constraints of nullable `anyOf`/`oneOf` fields are read from the non-null branch, and draft-04 boolean `exclusiveMinimum`/`exclusiveMaximum` are shown as exclusive bounds. Property descriptions now also split on U+2028/U+2029. A type over `maxChars` is rendered again without tags before it becomes `unknown` ([#10707](https://github.com/earendil-works/pi/issues/10707))
- `image()` errors describe the accepted image block as `{ type: "image", data, mimeType }` instead of an MCP image block
- The VM now polls for interrupts while it drains promise jobs, so a script such as `while (true) await null` is stopped at the same points as a synchronous loop
### Fixed

View file

@ -49,7 +49,7 @@ await sandbox.close();
- `tools.<name>(args)` returns a promise. Arguments and results make a JSON round trip. A tool that throws rejects with an `Error` carrying the same message. Tool names are also exposed as identifiers: characters that are not valid in identifiers become `_` (`toCodemodeIdentifier`), so `my-tool` is `tools.my_tool` as well as `tools["my-tool"]`.
- `ALL_TOOLS` lists `{ name, description }` for every tool, with `name` as the identifier.
- `text(value)` appends a text item to `result.output`; values other than strings are JSON-stringified. `console.log/info/warn/error/debug` append text items with `console: true`.
- `image(urlOrItem)` appends an image item. It accepts a base64 `data:` URL, `{ image_url }`, or an MCP `ImageContent` block (`{ type: "image", data, mimeType }`). Remote URLs are rejected.
- `image(urlOrItem)` appends an image item. It accepts a base64 `data:` URL, `{ image_url }`, or an image block (`{ type: "image", data, mimeType }`, as MCP tools and `@earendil-works/pi-ai` produce). Remote URLs are rejected.
- `exit()` ends the script successfully right away, keeping its output and store writes.
- `globals` passed to the sandbox are called as top-level functions, for example a host helper `image(ref)`. They behave like tools but are not recorded in `result.calls`. A name like `models.classify` puts the function on a frozen `models` object. With `spread: true`, `execute` receives all call arguments as an array instead of the first one, and `signature` replaces the declaration generated from the schemas.
- `store(key, value)` and `load(key)` read and write JSON values synchronously. See [Store](#store).

View file

@ -32,87 +32,6 @@ const MAX_TAG_CHARS = 80;
/** Characters that end a `//` comment. */
const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
/**
* TypeScript types for MCP results, from the MCP `CallToolResult` schema, so `CallToolResult<T>`
* declarations can refer to them.
*/
export const MCP_TYPESCRIPT_PREAMBLE = `type Role = "user" | "assistant";
type MetaObject = Record<string, unknown>;
type Annotations = {
audience?: Role[];
priority?: number;
lastModified?: string;
};
type Icon = {
src: string;
mimeType?: string;
sizes?: string[];
theme?: "light" | "dark";
};
type TextResourceContents = {
uri: string;
mimeType?: string;
_meta?: MetaObject;
text: string;
};
type BlobResourceContents = {
uri: string;
mimeType?: string;
_meta?: MetaObject;
blob: string;
};
type TextContent = {
type: "text";
text: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type ImageContent = {
type: "image";
data: string;
mimeType: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type AudioContent = {
type: "audio";
data: string;
mimeType: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type ResourceLink = {
icons?: Icon[];
name: string;
title?: string;
uri: string;
description?: string;
mimeType?: string;
annotations?: Annotations;
size?: number;
_meta?: MetaObject;
type: "resource_link";
};
type EmbeddedResource = {
type: "resource";
resource: TextResourceContents | BlobResourceContents;
annotations?: Annotations;
_meta?: MetaObject;
};
type ContentBlock =
| TextContent
| ImageContent
| AudioContent
| ResourceLink
| EmbeddedResource;
type CallToolResult<TStructured = { [key: string]: unknown }> = {
_meta?: MetaObject;
content: ContentBlock[];
isError?: boolean;
structuredContent?: TStructured;
[key: string]: unknown;
};`;
export interface RenderDeclarationsOptions {
tools?: readonly CodemodeTool[];
globals?: readonly CodemodeTool[];
@ -153,8 +72,6 @@ export function renderDeclarations(options: RenderDeclarationsOptions): string {
/**
* One tool as a member of the `tools` object: `name(args: T): Promise<R>;` with the
* name as the identifier scripts use. Input types longer than `inputMaxChars` render as `unknown`.
* Tools whose output schema is an MCP `CallToolResult` render as `Promise<CallToolResult<T>>`,
* which needs {@link MCP_TYPESCRIPT_PREAMBLE}.
*/
export function renderToolSignature(
tool: Pick<CodemodeTool, "name" | "inputSchema" | "outputSchema">,
@ -179,32 +96,8 @@ export function renderToolSample(
return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;
}
/**
* The `structuredContent` schema of an MCP `CallToolResult` output schema (detected by a
* `content` array of objects, boolean `isError`, and object `_meta`), `true` when it declares none, or
* `undefined` when the schema is not a `CallToolResult`.
*/
export function mcpStructuredContentSchema(schema: CodemodeJsonSchema | undefined): CodemodeJsonSchema | undefined {
if (!isObject(schema) || !isObject(schema.properties)) return undefined;
const { content, isError, _meta, structuredContent } = schema.properties;
if (!isObject(content) || content.type !== "array" || !isObject(content.items) || content.items.type !== "object") {
return undefined;
}
if (!isObject(isError) || isError.type !== "boolean" || !isObject(_meta) || _meta.type !== "object")
return undefined;
return isObject(structuredContent) || typeof structuredContent === "boolean" ? structuredContent : true;
}
/**
* The type a tool call resolves to: `CallToolResult<T>` for MCP output schemas (needs
* {@link MCP_TYPESCRIPT_PREAMBLE}), the schema's type otherwise, and `unknown` without a schema.
*/
/** The type a tool call resolves to: the output schema's type, and `unknown` without a schema. */
export function renderToolOutputType(schema: CodemodeJsonSchema | undefined): string {
const structured = mcpStructuredContentSchema(schema);
if (structured !== undefined) {
const type = schemaToType(structured);
return type === "unknown" ? "CallToolResult" : `CallToolResult<${type}>`;
}
return schema === undefined ? "unknown" : schemaToType(schema);
}
@ -229,10 +122,6 @@ function propertyKey(name: string): string {
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function union(types: string[]): string {
const unique = [...new Set(types)];
if (unique.includes("unknown")) return "unknown";
@ -269,16 +158,21 @@ function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema |
let current: unknown = root;
for (const segment of ref.slice(2).split("/").filter(Boolean)) {
const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~");
if (!isObject(current) || !(key in current)) return undefined;
current = current[key];
if (typeof current !== "object" || current === null || Array.isArray(current) || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return typeof current === "boolean" || isObject(current) ? current : undefined;
if (typeof current === "boolean") return current;
return typeof current === "object" && current !== null && !Array.isArray(current)
? (current as Record<string, unknown>)
: undefined;
}
function toType(schema: CodemodeJsonSchema, context: SchemaContext): string {
if (schema === true) return "unknown";
if (schema === false) return "never";
if (!isObject(schema)) return "unknown";
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "unknown";
if (typeof schema.$ref === "string") {
const ref = schema.$ref;
if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown";
@ -348,21 +242,30 @@ function arrayType(schema: Record<string, unknown>, context: SchemaContext): str
}
function descriptionOf(property: unknown): string {
return isObject(property) && typeof property.description === "string" ? property.description.trim() : "";
const description = (property as { description?: unknown } | null | undefined)?.description;
return typeof description === "string" ? description.trim() : "";
}
/**
* Constraints of a property that its TypeScript type cannot express, as JSDoc-style tags:
* `@integer @minimum 1 @maximum 100 @default 20`. Empty when there are none.
*/
function constraintTags(property: unknown): string {
if (!isObject(property)) return "";
function constraintTags(schema: unknown): string {
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "";
const property = schema as Record<string, unknown>;
// Optional fields such as Pydantic's `int | None` keep their constraints in the non-null branch.
// The branch's constraints apply together with the property's, so both are read.
const variants = Array.isArray(property.anyOf) ? property.anyOf : property.oneOf;
const nonNull = Array.isArray(variants) ? variants.filter((v) => !isObject(v) || v.type !== "null") : [];
const nonNull: unknown[] = Array.isArray(variants)
? variants.filter((variant) => (variant as { type?: unknown } | null)?.type !== "null")
: [];
const branch = nonNull.length === 1 ? nonNull[0] : undefined;
const branches =
typeof branch === "object" && branch !== null && !Array.isArray(branch)
? [property, branch as Record<string, unknown>]
: [property];
// Draft-04 and OpenAPI 3.0 make `minimum`/`maximum` exclusive with a boolean `exclusiveMinimum`/`exclusiveMaximum`.
const schemas = (nonNull.length === 1 && isObject(nonNull[0]) ? [property, nonNull[0]] : [property]).map(
const schemas = branches.map(
(schema): Record<string, unknown> => ({
...schema,
...(schema.exclusiveMinimum === true ? { minimum: undefined, exclusiveMinimum: schema.minimum } : {}),
@ -398,7 +301,10 @@ function constraintTags(property: unknown): string {
}
function objectType(schema: Record<string, unknown>, context: SchemaContext): string {
const properties = isObject(schema.properties) ? schema.properties : {};
const properties =
typeof schema.properties === "object" && schema.properties !== null && !Array.isArray(schema.properties)
? (schema.properties as Record<string, unknown>)
: {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const names = Object.keys(properties).sort();
const members = names.map((name) => {

View file

@ -1,7 +1,5 @@
export {
DEFAULT_INPUT_SCHEMA_MAX_CHARS,
MCP_TYPESCRIPT_PREAMBLE,
mcpStructuredContentSchema,
type RenderDeclarationsOptions,
renderDeclarations,
renderToolOutputType,

View file

@ -9,7 +9,7 @@
*
* Output helpers: `text(value)` appends a text item (non-strings are
* JSON-stringified), `image(urlOrItem)` appends an image from a base64 `data:` URL, an
* `{ image_url }` object, or an MCP `ImageContent` block, and `exit()` ends the script
* `{ image_url }` object, or an image block `{ type: "image", data, mimeType }`, and `exit()` ends the script
* successfully. `console.*` appends text items marked as console output.
*
* `store(key, value)` and `load(key)` are synchronous: they work on a snapshot of
@ -43,7 +43,7 @@ export const MAX_OUTPUT_CHARS = 16 * 1024 * 1024;
export const MAX_OUTPUT_ITEMS = 100_000;
const IMAGE_HELPER_EXPECTS =
"image expects a non-empty image URL string, an object with image_url, or a raw MCP image block";
'image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }';
export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson, storeJson) {
"use strict";
@ -384,9 +384,9 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
}
if (typeof value.type !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
if (value.type !== "image") {
throw new TypeErrorCtor('image only accepts MCP image blocks, got "' + value.type + '"');
throw new TypeErrorCtor('image only accepts image blocks, got "' + value.type + '"');
}
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected MCP image data");
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected image block data");
if (value.data.toLowerCase().startsWith("data:")) return value.data;
return "data:;base64," + value.data;
}

View file

@ -1,27 +1,8 @@
import { describe, expect, it } from "vitest";
import {
mcpStructuredContentSchema,
renderDeclarations,
renderToolSample,
renderToolSignature,
schemaToType,
} from "../src/index.ts";
import { renderDeclarations, renderToolSample, renderToolSignature, schemaToType } from "../src/index.ts";
const execute = () => undefined;
function mcpResultSchema(structuredContent?: unknown) {
return {
type: "object",
properties: {
content: { type: "array", items: { type: "object" } },
...(structuredContent === undefined ? {} : { structuredContent }),
isError: { type: "boolean" },
_meta: { type: "object" },
},
required: ["content"],
};
}
describe("schemaToType", () => {
it("renders primitives, literals, and unions", () => {
expect(schemaToType({ type: "string" })).toBe("string");
@ -258,36 +239,21 @@ describe("tool declarations", () => {
expect(renderToolSignature({ name: "free" })).toBe("free(args: unknown): Promise<unknown>;");
});
it("renders MCP CallToolResult output schemas as CallToolResult<T>", () => {
const inputSchema = { type: "object", properties: {}, additionalProperties: false };
it("renders output schemas shaped like an MCP CallToolResult as plain types", () => {
const outputSchema = {
type: "object",
properties: {
content: { type: "array", items: { type: "object" } },
isError: { type: "boolean" },
_meta: { type: "object" },
},
required: ["content"],
};
expect(
renderToolSignature({
name: "mcp__sample__search",
inputSchema,
outputSchema: mcpResultSchema({
type: "object",
properties: { results: { type: "array", items: { $ref: "#/definitions/Result~1item~0v1" } } },
required: ["results"],
additionalProperties: false,
definitions: {
"Result/item~v1": {
type: "object",
properties: { id: { type: "string" }, score: { type: "number" } },
required: ["id", "score"],
additionalProperties: false,
},
},
}),
}),
renderToolSignature({ name: "plain", inputSchema: { type: "object", properties: {} }, outputSchema }),
).toBe(
"mcp__sample__search(args: {}): Promise<CallToolResult<{ results: Array<{ id: string; score: number; }>; }>>;",
"plain(args: { [key: string]: unknown; }): Promise<{ _meta?: { [key: string]: unknown; }; content: Array<{ [key: string]: unknown; }>; isError?: boolean; }>;",
);
expect(renderToolSignature({ name: "plain", inputSchema, outputSchema: mcpResultSchema() })).toBe(
"plain(args: {}): Promise<CallToolResult>;",
);
expect(
mcpStructuredContentSchema({ type: "object", properties: { content: { type: "array" } } }),
).toBeUndefined();
});
it("renders the per-tool sample", () => {

View file

@ -163,12 +163,12 @@ describe.each(Object.keys(BACKENDS))("%s backend", (name) => {
const errors = result.value as string[];
expect(errors[0]).toMatch(/^TypeError: .*circular/);
expect(errors.slice(1)).toEqual([
"TypeError: image expects a non-empty image URL string, an object with image_url, or a raw MCP image block",
'TypeError: image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }',
"TypeError: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead",
"TypeError: invalid image output. Pass a base64 data URI instead",
'TypeError: image only accepts MCP image blocks, got "text"',
"TypeError: image expected MCP image data",
"TypeError: image expects a non-empty image URL string, an object with image_url, or a raw MCP image block",
'TypeError: image only accepts image blocks, got "text"',
"TypeError: image expected image block data",
'TypeError: image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }',
...Array(6).fill(
"TypeError: invalid image output. The image data is not valid base64 (truncated or corrupted?)",
),

View file

@ -6,10 +6,16 @@
- Published generated configuration schemas for models, settings, keybindings, and themes at `https://pi.dev/schemas/*.schema.json`. The theme schema moved to `https://pi.dev/schemas/theme.schema.json`; themes loaded during startup now receive the same schema validation as later reloads. Update theme `$schema` references, define reusable colors under `vars`, and remove unsupported metadata.
### Added
- Added `reachesTools` to tool definitions, so tools that reach other tools (such as `codemode` and `tool_search`) declare which tools a call may reach; the MCP extension uses it to wait for the servers a call needs and to decide whether a replacement `codemode` or `tool_search` tool can reach MCP tools
- Added `aliases` to tool namespaces; codemode's `describeNamespace()` and the `namespace` option of `searchTools()` match them, and MCP namespaces use the server name
### Changed
- Changed the `showHardwareCursor` setting to use only the terminal cursor instead of also drawing Pi's reverse-video cursor
- Changed codemode tool declarations to show input constraints such as integer, minimum/maximum, length, pattern, and default as `@` tags on property comments, so scripts see them without the tool repeating them in descriptions ([#10707](https://github.com/earendil-works/pi/issues/10707))
- Changed codemode declarations of MCP tools to spell out the result type (`content`, `structuredContent`, `isError`) instead of referring to `CallToolResult<T>`, which `describeTool()`, `searchTools()`, and `ALL_TOOLS` showed without defining it; the codemode description no longer includes the shared MCP types
- Changed `serializeConversation()` (compaction, branch summaries, bug reports) to omit thinking blocks; replayed thinking is often not in the live context, so including it could make a summarization request larger than the context it summarizes and fail
### Fixed

View file

@ -159,9 +159,9 @@ See [`hello.ts`](../examples/extensions/hello.ts), [`todo.ts`](../examples/exten
- `deferred`: like `codemode`, but codemode tools do not list it; `tool_search` can find and activate it.
- `hidden`: registered but unreachable. Re-register a tool with `exposure: "hidden"` to withdraw it, since tools cannot be unregistered.
`namespace: { name, description, instructions }` groups related tools, as MCP servers do. Codemode tools list a namespace under one heading with its `description`. `instructions` holds longer usage guidance; it is not listed, and codemode scripts read it with `describeNamespace(name)`.
`namespace: { name, aliases, description, instructions }` groups related tools, as MCP servers do. Codemode tools list a namespace under one heading with its `description`. `instructions` holds longer usage guidance; it is not listed, and codemode scripts read it with `describeNamespace(name)`. `aliases` are other names scripts may look the namespace up by, for example the server name `docs` for `mcp__docs`; a name and the same name as a script identifier (`my-docs`, `my_docs`) match alike.
Registering a `direct` or `model-only` tool activates it; the other exposures are not activated on registration. The active set (`pi.getActiveTools()`, `pi.setActiveTools()`) is the set of tools declared to the model. `pi.getAllTools()` reports each tool's `exposure`, `namespace`, and `annotations`.
Registering a `direct` or `model-only` tool activates it; the other exposures are not activated on registration. The active set (`pi.getActiveTools()`, `pi.setActiveTools()`) is the set of tools declared to the model. `pi.getAllTools()` reports each tool's `exposure`, `namespace`, `annotations`, and `reachesTools`.
`annotations` are hints about what a tool does, with the meaning of MCP tool annotations: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. MCP tools carry the hints their server declares. Missing hints take the MCP defaults: a tool is not read-only, and may be destructive and reach an open world. The hints are not verified, but a permission extension can use them to decide which calls to confirm. This confirms the calls Codex asks approval for:
@ -177,7 +177,9 @@ pi.on("tool_call", async (event, ctx) => {
});
```
A tool that orchestrates other tools can adjust what the model sees while it is active with `prepareLoadout(loadout)`. It runs whenever the active tools change and receives the declared tools, the callable tools, and every registered tool with its exposure, namespace, and prompt guidelines. It returns replacement `descriptions` for declared tools (including its own) and `hiddenDeclarations`: active tools whose declarations requests leave out while they stay active and callable. The default system prompt leaves hidden tools out of its tool list and rules, and names no tool in the skills hint when the file reader is hidden, so the orchestrating tool should present their guidelines itself. `codemode` uses only this hook, `exposure`, and `ctx.executeTool()`, so another tool can implement the same behavior under a different name.
A tool that orchestrates other tools can adjust what the model sees while it is active with `prepareLoadout(loadout)`. It runs whenever the active tools change and receives the declared tools, the callable tools, and every registered tool with its exposure, namespace, and prompt guidelines. It returns replacement `descriptions` for declared tools (including its own) and `hiddenDeclarations`: active tools whose declarations requests leave out while they stay active and callable. The default system prompt leaves hidden tools out of its tool list and rules, and names no tool in the skills hint when the file reader is hidden, so the orchestrating tool should present their guidelines itself. `codemode` uses only this hook, `exposure`, `reachesTools`, and `ctx.executeTool()`, so another tool can implement the same behavior under a different name.
A tool that reaches other tools declares which ones a call may reach with `reachesTools(input)`: `"all"`, or the words of the input that may name them, where a word refers to every tool or namespace whose name it contains. `codemode` returns `"all"` for scripts that search or list tools and the words of the script otherwise; `tool_search` always returns `"all"`. Extensions that register tools late use it from their `tool_call` handler to finish registering the tools a call needs before it runs, as the MCP extension does for servers that are still connecting. The MCP extension also activates `codemode` or `tool_search` for MCP tools only when the tool of that name declares `reachesTools`.
### Activate tools dynamically

View file

@ -265,7 +265,7 @@ Changes to enabled state or exposure apply only to the current session. A file-c
An installed extension that registers `/mcp`, such as `pi-mcp-adapter`, replaces the built-in MCP support for sessions. Pi then does not read `mcp.json` or connect its servers in a session, and `/mcp` belongs to the extension. Remove the extension to restore the built-in behavior. To disable built-in MCP support without a replacement, disable `mcp` under Built-in in `pi config`, or set `"extensions": ["-builtin:mcp"]` in [settings](settings.md#resources). `--no-mcp` disables it for one run.
An extension that registers `codemode` or `tool_search` similarly replaces the built-in tool with that name. Shell-level `pi mcp` commands always use the built-in implementation.
An extension that registers `codemode` or `tool_search` similarly replaces the built-in tool with that name. Pi activates the replacement for MCP servers and waits for the servers its calls need only when it declares [`reachesTools`](extensions.md#tool-exposure). Shell-level `pi mcp` commands always use the built-in implementation.
### Use MCP from the SDK

View file

@ -1501,6 +1501,7 @@ export class AgentSession {
exposure: this._getToolExposure(definition.name),
...(definition.namespace ? { namespace: definition.namespace } : {}),
...(definition.annotations ? { annotations: { ...definition.annotations } } : {}),
...(definition.reachesTools ? { reachesTools: definition.reachesTools } : {}),
sourceInfo,
}));
}

View file

@ -534,6 +534,11 @@ export interface ToolAnnotations {
export interface ToolNamespace {
/** For example `mcp__docs`. */
name: string;
/**
* Other names that refer to the namespace where tools look it up by name (codemode's
* `describeNamespace()` and the `namespace` option of `searchTools()`), for example `docs`.
*/
aliases?: readonly string[];
/** Short summary shown once with the group in model-facing tool listings. */
description?: string;
/**
@ -606,6 +611,15 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
/** Group the tool belongs to, for example its MCP server. */
namespace?: ToolNamespace;
/**
* For tools that reach other tools (such as `codemode`, which calls them, and `tool_search`, which
* loads them): which tools a call with `input` may reach. `"all"`, or the words of the input that
* may name them, where a word refers to a tool or namespace whose name it contains, so words may
* over-match. Extensions that register tools late, such as MCP servers that are still connecting,
* use it from their `tool_call` handler to finish registering those tools before the call runs.
*/
reachesTools?: (input: Record<string, unknown>) => "all" | readonly string[];
/** Hints about what the tool does, for example from an MCP server. */
annotations?: ToolAnnotations;
@ -2091,7 +2105,10 @@ export type GetSessionNameHandler = () => string | undefined;
export type GetActiveToolsHandler = () => string[];
/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
export type ToolInfo = Pick<
ToolDefinition,
"name" | "description" | "parameters" | "promptGuidelines" | "reachesTools"
> & {
exposure: ToolExposure;
namespace?: ToolNamespace;
annotations?: ToolAnnotations;

View file

@ -99,9 +99,8 @@ function withArticle(word: string): string {
return `${/^[aeiou]/.test(word) ? "an" : "a"} ${word}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** A JSON value from a script, read field by field: values that are not objects have no fields. */
type ScriptValue = { readonly [key: string]: unknown } | null | undefined;
/** A script value in an error message: `undefined`, `a string`, `an array`, or its keys (`{ prompt }`). */
function describeValue(value: unknown): string {
@ -124,46 +123,57 @@ function checkClassifierContext(context: unknown): ClassifierContext {
new Error(
`models.classify() ${problem}. Expected context: ${CLASSIFIER_CONTEXT_SHAPE}. See "Classify" in ${CODEMODE_DOCS_PATH}.`,
);
if (!isRecord(context)) throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
if (!isRecord(context.state)) throw fail(`context.state must be an object, got ${describeValue(context.state)}`);
const { images } = context;
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
}
const { state, images, questions } = context as Record<string, unknown>;
if (typeof state !== "object" || state === null || Array.isArray(state)) {
throw fail(`context.state must be an object, got ${describeValue(state)}`);
}
if (images !== undefined) {
if (!Array.isArray(images)) throw fail(`context.images must be an array, got ${describeValue(images)}`);
images.forEach((image: unknown, index) => {
if (
!isRecord(image) ||
image.type !== "image" ||
typeof image.data !== "string" ||
typeof image.mimeType !== "string"
) {
images.forEach((image: ScriptValue, index) => {
if (image?.type !== "image" || typeof image.data !== "string" || typeof image.mimeType !== "string") {
throw fail(`context.images[${index}] must be an image block, got ${describeValue(image)}`);
}
});
}
const { questions } = context;
if (!isRecord(questions) || Object.keys(questions).length === 0) {
if (
typeof questions !== "object" ||
questions === null ||
Array.isArray(questions) ||
Object.keys(questions).length === 0
) {
throw fail(`context.questions must map question IDs to questions, got ${describeValue(questions)}`);
}
const isStrings = (values: unknown[]) => values.length > 0 && values.every((value) => typeof value === "string");
for (const [id, question] of Object.entries(questions)) {
const at = `context.questions.${id}`;
if (!isRecord(question)) throw fail(`${at} must be a question object, got ${describeValue(question)}`);
if (typeof question.instructions !== "string") throw fail(`${at}.instructions must be a string`);
const { criteria } = question;
if (question.type === "choice") {
if (!isRecord(criteria) || !isStrings(Object.values(criteria))) {
if (typeof question !== "object" || question === null || Array.isArray(question)) {
throw fail(`${at} must be a question object, got ${describeValue(question)}`);
}
const { type, instructions, criteria } = question as Record<string, unknown>;
if (typeof instructions !== "string") throw fail(`${at}.instructions must be a string`);
if (type === "choice") {
if (
typeof criteria !== "object" ||
criteria === null ||
Array.isArray(criteria) ||
!isStrings(Object.values(criteria))
) {
throw fail(`${at} is a "choice" question, so criteria must map each label to its meaning`);
}
} else if (question.type === "score") {
} else if (type === "score") {
if (!Array.isArray(criteria) || !isStrings(criteria)) {
throw fail(`${at} is a "score" question, so criteria must list the levels as strings, lowest first`);
}
} else if (question.type === "bool") {
if (!isRecord(criteria) || typeof criteria.true !== "string" || typeof criteria.false !== "string") {
} else if (type === "bool") {
const labels = criteria as ScriptValue;
if (typeof labels?.true !== "string" || typeof labels.false !== "string") {
throw fail(`${at} is a "bool" question, so criteria must be { true: string, false: string }`);
}
} else {
throw fail(`${at}.type must be "choice", "score", or "bool", got ${JSON.stringify(question.type)}`);
throw fail(`${at}.type must be "choice", "score", or "bool", got ${JSON.stringify(type)}`);
}
}
return context as unknown as ClassifierContext;
@ -175,21 +185,16 @@ function checkImagesContext(context: unknown): ImagesContext {
new Error(
`models.generateImages() ${problem}. Expected context: { input: [{ type: "text", text: <prompt> }, ...optional { type: "image", data: <base64>, mimeType } references] }. See "Generate images" in ${CODEMODE_DOCS_PATH}.`,
);
if (!isRecord(context)) throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
const { input } = context;
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
}
const { input } = context as Record<string, unknown>;
if (!Array.isArray(input) || input.length === 0) {
throw fail(`context.input must be a non-empty array of blocks, got ${describeValue(input)}`);
}
input.forEach((block: unknown, index) => {
if (isRecord(block) && block.type === "text" && typeof block.text === "string") return;
if (
isRecord(block) &&
block.type === "image" &&
typeof block.data === "string" &&
typeof block.mimeType === "string"
) {
return;
}
input.forEach((block: ScriptValue, index) => {
if (block?.type === "text" && typeof block.text === "string") return;
if (block?.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") return;
throw fail(`context.input[${index}] must be a text or image block, got ${describeValue(block)}`);
});
return context as unknown as ImagesContext;
@ -537,14 +542,14 @@ export async function executeCodemode(
}
/**
* Whether `query` names the namespace: its name, its script identifier (`mcp__dev-radius` is
* `mcp__dev_radius`), or the part after its last `__` in either form (`dev-radius`, `dev_radius`).
* Whether `query` names the namespace: its name or one of its aliases, as written or as the same
* script identifier (`dev-radius` and `dev_radius` are both `dev_radius`).
*/
function isNamespaceName(namespace: string, query: string): boolean {
const id = toCodemodeIdentifier(namespace);
function isNamespaceName(namespace: ToolNamespace, query: string): boolean {
const queryId = toCodemodeIdentifier(query);
const suffix = (name: string) => (name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : undefined);
return namespace === query || id === queryId || suffix(namespace) === query || suffix(id) === queryId;
return [namespace.name, ...(namespace.aliases ?? [])].some(
(name) => name === query || toCodemodeIdentifier(name) === queryId,
);
}
/**
@ -575,7 +580,7 @@ function createDiscoveryGlobals(
}
const documents = tools.flatMap((tool) => {
const toolNamespace = options.getToolNamespace?.(tool.name);
if (namespace && (!toolNamespace || !isNamespaceName(toolNamespace.name, namespace))) return [];
if (namespace && (!toolNamespace || !isNamespaceName(toolNamespace, namespace))) return [];
return [createToolSearchDocument(tool, toolNamespace)];
});
return ranker.rank(query, documents, limit).map((match) => entry(match.name));
@ -603,7 +608,7 @@ function createDiscoveryGlobals(
const names: string[] = [];
for (const tool of tools) {
const toolNamespace = options.getToolNamespace?.(tool.name);
if (!toolNamespace || !isNamespaceName(toolNamespace.name, name)) continue;
if (!toolNamespace || !isNamespaceName(toolNamespace, name)) continue;
namespace ??= toolNamespace;
names.push(toCodemodeIdentifier(tool.name));
}
@ -647,7 +652,8 @@ function createModelGlobals(
run: (resolved: ModelTypeMap[TType], context: TContext) => Promise<TResult>,
): Promise<TResult> => {
const listHint = `List the ${type} models you can use with models.getAvailableOfType("${type}").`;
if (!isRecord(model) || typeof model.provider !== "string" || typeof model.id !== "string") {
const given = model as ScriptValue;
if (typeof given?.provider !== "string" || typeof given.id !== "string") {
// undefined arrives as null: spread arguments cross the sandbox as a JSON array.
const undefinedHint =
model === undefined || model === null
@ -657,7 +663,8 @@ function createModelGlobals(
`${name}() expects ${withArticle(type)} model as its first argument, got ${describeValue(model)}.${undefinedHint} ${listHint}`,
);
}
const { provider, id } = model;
const provider = given.provider;
const id = given.id;
const ref = `${provider}/${id}`;
const resolved = models.getModelOfType(type, provider, id);
if (!resolved) {

View file

@ -11,7 +11,7 @@
*
* Nested results are handed to the script as follows:
* - A tool that declares `outputSchema` resolves to its `structuredContent`, also for error
* results that carry one (MCP tools resolve to their `CallToolResult`, including `isError`).
* results that carry one.
* - Any other tool resolves to its text content as one string.
* - A failed, blocked, or invalid call rejects with an Error carrying the tool's error text.
*
@ -24,23 +24,11 @@
import { join } from "node:path";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { CodemodeJsonSchema, CodemodeTool } from "@earendil-works/pi-codemode";
import {
MCP_TYPESCRIPT_PREAMBLE,
mcpStructuredContentSchema,
renderToolOutputType,
renderToolSample,
toCodemodeIdentifier,
} from "@earendil-works/pi-codemode/declarations";
import { renderToolOutputType, renderToolSample, toCodemodeIdentifier } from "@earendil-works/pi-codemode/declarations";
import { CODEMODE_SOURCE_GRAMMAR } from "@earendil-works/pi-codemode/source";
import { type Static, Type } from "typebox";
import { getDocsPath } from "../../config.ts";
import type {
ToolDefinition,
ToolInfo,
ToolLoadout,
ToolLoadoutChanges,
ToolNamespace,
} from "../../core/extensions/types.ts";
import type { ToolDefinition, ToolLoadout, ToolLoadoutChanges, ToolNamespace } from "../../core/extensions/types.ts";
import type { ModelRegistry } from "../../core/model-registry.ts";
import { SETTINGS_DEFAULTS } from "../../core/settings-defaults.ts";
import type { CodemodeMode } from "../../core/settings-manager.ts";
@ -95,14 +83,6 @@ export const codemodeSchema = Type.Object({
export type CodemodeToolInput = Static<typeof codemodeSchema>;
/**
* Whether a registered tool is this package's `codemode` tool rather than another extension's tool
* with the same name. Compares the parameter schema, which the definition passes through by reference.
*/
export function isCodemodeTool(tool: Pick<ToolInfo, "name" | "parameters">): boolean {
return tool.name === CODEMODE_TOOL_NAME && tool.parameters === codemodeSchema;
}
export type CodemodeNestedCallStatus = "running" | "ok" | "error" | "cancelled";
export interface CodemodeNestedCall {
@ -241,10 +221,9 @@ function selectCatalog(groups: readonly CatalogGroup[], budget: number | undefin
/**
* Model-facing description: the helper list, guidance for finding tools that are not listed, the
* shared MCP types when listed tools need them, the `models` API, and one section per listed tool,
* grouped by namespace. Deferred tools are never listed and do not affect the description at all, so
* it stays the same while MCP servers connect or change their tools. Tool sections are limited to
* `inlineBudget`.
* `models` API, and one section per listed tool, grouped by namespace. Deferred tools are never
* listed and do not affect the description at all, so it stays the same while tools are registered
* late (for example by MCP servers that connect). Tool sections are limited to `inlineBudget`.
*/
export function createCodemodeDescription(
tools: readonly AgentTool<any>[],
@ -268,14 +247,6 @@ export function createCodemodeDescription(
const shown = selectCatalog(ordered, options.inlineBudget);
const sections = [DESCRIPTION_INTRO, describeGlobals(options.models === true)];
if (
declarations.some(
(declaration) =>
shown.has(declaration.name) && mcpStructuredContentSchema(declaration.outputSchema) !== undefined,
)
) {
sections.push(`Shared MCP Types:\n\`\`\`ts\n${MCP_TYPESCRIPT_PREAMBLE}\n\`\`\``);
}
if (declarations.length === 0) return sections.join("\n\n");
const toolSections = ["Nested tools:"];
@ -299,25 +270,31 @@ export function createCodemodeDescription(
}
/**
* What a script call resolves to, in one line: `a string`, the field names of an object
* (`{ output, exit_code, full_output_path? }`), or the rendered type for anything else.
* The field names of an object schema, `{ output, exit_code, full_output_path? }`, with the fields of
* object-typed fields `depth` levels deep: `{ content, structuredContent?: { results } }`. Undefined
* for other schemas.
*/
function describeFields(schema: unknown, depth: number): string | undefined {
if (typeof schema !== "object" || schema === null) return undefined;
const { type, properties, required } = schema as Record<string, unknown>;
if (type !== "object" || typeof properties !== "object" || properties === null) return undefined;
const requiredNames = new Set(Array.isArray(required) ? required : []);
const fields = Object.entries(properties).map(([name, field]) => {
const nested = depth > 0 ? describeFields(field, depth - 1) : undefined;
return `${name}${requiredNames.has(name) ? "" : "?"}${nested ? `: ${nested}` : ""}`;
});
return `{ ${fields.join(", ")} }`;
}
/**
* What a script call resolves to, in one line: `a string`, the field names of an object (see
* {@link describeFields}), or the rendered type for anything else.
*/
function describeOutput(schema: CodemodeJsonSchema | undefined): string {
const type = renderToolOutputType(schema);
if (type === "string") return "a string";
const object = typeof schema === "object" ? schema : undefined;
const properties = object?.properties;
if (
object?.type === "object" &&
typeof properties === "object" &&
properties !== null &&
mcpStructuredContentSchema(schema) === undefined
) {
const required = new Set(Array.isArray(object.required) ? object.required : []);
const fields = Object.keys(properties).map((name) => (required.has(name) ? name : `${name}?`));
return `\`{ ${fields.join(", ")} }\``;
}
return `\`${type.replace(/\s+/g, " ")}\``;
const fields = describeFields(schema, 1);
return `\`${fields ?? type.replace(/\s+/g, " ")}\``;
}
/**
@ -378,6 +355,19 @@ function prepareCodemodeLoadout(loadout: ToolLoadout, options: CodemodeToolOptio
};
}
/** Script globals that list or look up tools and namespaces, which the script may then call by any name. */
const DISCOVERY_GLOBALS = /\b(searchTools|describeNamespace|describeTool|ALL_TOOLS)\b/;
/**
* The tools a script may call (`ToolDefinition.reachesTools`): every tool when it uses a discovery
* global, else the words of its source, so `tools.mcp__docs__search` and
* `tools["mcp__docs__" + name]` both name the `mcp__docs` namespace.
*/
function getScriptReach(code: unknown): "all" | readonly string[] {
if (typeof code !== "string") return [];
return DISCOVERY_GLOBALS.test(code) ? "all" : (code.match(/\w+/g) ?? []);
}
export function createCodemodeToolDefinition(
options: CodemodeToolOptions = {},
): ToolDefinition<typeof codemodeSchema, CodemodeToolDetails | undefined> {
@ -392,6 +382,7 @@ export function createCodemodeToolDefinition(
// Scripts must not start other scripts.
exposure: "model-only",
prepareLoadout: (loadout) => prepareCodemodeLoadout(loadout, options),
reachesTools: (input) => getScriptReach(input.code),
// Capable models write the script as raw text instead of a JSON-escaped string.
constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } },
// The sandbox (worker, QuickJS wasm) loads on the first call, not at startup.

View file

@ -40,8 +40,8 @@ import type {
import { mcpNamespace } from "../../core/mcp-servers.ts";
import type { ModelRegistry } from "../../core/model-registry.ts";
import { openBrowser } from "../../utils/open-browser.ts";
import { CODEMODE_TOOL_NAME, isCodemodeTool } from "../codemode/tool.ts";
import { isToolSearchTool, TOOL_SEARCH_TOOL_NAME } from "../tool-search/tool.ts";
import { CODEMODE_TOOL_NAME } from "../codemode/tool.ts";
import { TOOL_SEARCH_TOOL_NAME } from "../tool-search/tool.ts";
import {
getMcpToolExposure,
type LoadedMcpConfig,
@ -221,12 +221,13 @@ export function renderServersSection(servers: readonly McpServerListing[]): stri
}
/**
* Whether a codemode script needs the server: it names the server's namespace, or searches,
* enumerates, or describes tools or namespaces, which may name the server in other forms.
* Whether a call that reaches `reach` (see `ToolDefinition.reachesTools`) needs the server: it
* reaches every tool, or one of its words contains the server's namespace.
*/
function scriptNeedsServer(code: string, server: string): boolean {
if (/\b(searchTools|describeNamespace|describeTool|ALL_TOOLS)\b/.test(code)) return true;
return code.includes(mcpNamespace(server));
function reachesServer(reach: "all" | readonly string[], server: string): boolean {
if (reach === "all") return true;
const namespace = mcpNamespace(server);
return reach.some((word) => word.includes(namespace));
}
/** Short state for lists and the startup report. `withError` appends the first line of a failure. */
@ -391,6 +392,8 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
const description = entry.config.description?.trim();
const namespace = {
name: mcpNamespace(server),
// `describeNamespace("docs")` finds `mcp__docs`.
aliases: [server],
...(description ? { description } : {}),
...(connection.instructions ? { instructions: connection.instructions } : {}),
};
@ -507,10 +510,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
const needsCodemode = exposures.has("codemode");
const needsToolSearch = exposures.has("deferred");
if (!needsCodemode && !needsToolSearch) return;
// Other extensions' tools of the same names cannot reach MCP tools, so never activate them.
// Only a tool of these names that declares `reachesTools` can reach MCP tools; another
// extension's tool of the same name without it is never activated.
const tools = pi.getAllTools();
const hasCodemode = tools.some(isCodemodeTool);
const hasToolSearch = tools.some(isToolSearchTool);
const hasReachingTool = (name: string) => tools.some((tool) => tool.name === name && tool.reachesTools);
const hasCodemode = hasReachingTool(CODEMODE_TOOL_NAME);
const hasToolSearch = hasReachingTool(TOOL_SEARCH_TOOL_NAME);
const active = pi.getActiveTools();
const activate: string[] = [];
if (needsCodemode && hasCodemode && autoEnableCodemode && !active.includes(CODEMODE_TOOL_NAME)) {
@ -1180,10 +1185,11 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
else delete sections[MCP_SERVERS_SECTION];
});
// A codemode script waits for the servers it names, or for every server when it searches or
// enumerates tools, so their tools are registered before the script runs. tool_search and the
// resource tools reach every server, so they wait for all of them. Direct MCP calls wait only
// for their own server, including a reconnect still closing its old transport.
// A tool that reaches other tools (`reachesTools`) waits for the servers it may reach, so their
// tools are registered before it runs: a codemode script for the servers it names, or for every
// server when it searches or enumerates tools, and tool_search for every server. The resource
// tools reach every server, so they wait for all of them. Direct MCP calls wait only for their
// own server, including a reconnect still closing its old transport.
pi.on("tool_call", async (event, ctx) => {
const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
if (!tool) return;
@ -1192,11 +1198,10 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension
const readyServers = servers.filter((server) => isEnabled(server) && server.ready);
if (readyServers.length === 0) return;
let waiting: McpServer[] = [];
if (isCodemodeTool(tool)) {
const { code } = event.input as { code?: unknown };
const source = typeof code === "string" ? code : "";
waiting = readyServers.filter((server) => scriptNeedsServer(source, server.entry.name));
} else if (isToolSearchTool(tool) || RESOURCE_TOOL_NAMES.has(tool.name)) {
const reach = tool.reachesTools?.(event.input);
if (reach !== undefined) {
waiting = readyServers.filter((server) => reachesServer(reach, server.entry.name));
} else if (RESOURCE_TOOL_NAMES.has(tool.name)) {
waiting = readyServers;
} else {
const owner = toolOwners.get(event.toolName)?.split("\0", 1)[0];

View file

@ -100,20 +100,76 @@ function textOf(content: readonly (TextContent | ImageContent)[]): string {
}
/**
* Output schema of every MCP tool: the `CallToolResult` scripts receive, with the tool's own output
* schema as `structuredContent`. Codemode detects this shape to render `CallToolResult<T>`
* declarations.
* 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: ReadonlySet<string> = new Set(["const", "enum", "default", "examples"]);
/** Keywords whose values map names to schemas, so their keys are names, not keywords. */
const SCHEMA_MAP_KEYWORDS: ReadonlySet<string> = 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: unknown, pointer: string): unknown {
if (Array.isArray(schema)) return schema.map((item) => relocateSchema(item, pointer));
if (typeof schema !== "object" || schema === null) return schema;
const relocated: Record<string, unknown> = {};
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: Record<string, unknown> | undefined): TSchema {
return {
type: "object",
properties: {
content: { type: "array", items: { type: "object" } },
...(structuredContentSchema ? { structuredContent: structuredContentSchema } : {}),
content: { type: "array", items: CONTENT_BLOCK_SCHEMA },
// Servers may send structured content without declaring a schema for it.
structuredContent: structuredContentSchema
? relocateSchema(structuredContentSchema, "#/properties/structuredContent")
: { type: "object" },
isError: { type: "boolean" },
_meta: { type: "object" },
},
required: ["content"],
// Fields newer protocol versions add.
additionalProperties: true,
} as unknown as TSchema;
}

View file

@ -165,11 +165,6 @@ export const toolSearchSchema = Type.Object({
export type ToolSearchInput = Static<typeof toolSearchSchema>;
/** Whether the tool is this `tool_search`, not another extension's tool of the same name. */
export function isToolSearchTool(tool: Pick<ToolInfo, "name" | "parameters">): boolean {
return tool.name === TOOL_SEARCH_TOOL_NAME && tool.parameters === toolSearchSchema;
}
export interface ToolSearchResultTool {
name: string;
description: string;
@ -230,6 +225,8 @@ export function createToolSearchToolDefinition(
parameters: toolSearchSchema,
// Searching is not something scripts need; it changes what the model sees.
exposure: "model-only",
// A query can match any tool that is not declared yet.
reachesTools: () => "all",
async execute(_toolCallId, { query, limit }) {
if (query.trim() === "") throw new Error("query must not be empty");
const max = limit ?? DEFAULT_TOOL_SEARCH_LIMIT;

View file

@ -3,6 +3,7 @@ import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { renderToolOutputType } from "@earendil-works/pi-codemode/declarations";
import {
type JsonRpcMessage,
LATEST_PROTOCOL_VERSION,
@ -27,7 +28,7 @@ import {
McpServerConnection,
McpServerLog,
} from "../src/extensions/mcp/runtime.ts";
import { convertMcpResult, createMcpToolName } from "../src/extensions/mcp/tools.ts";
import { convertMcpResult, createMcpResultSchema, createMcpToolName } from "../src/extensions/mcp/tools.ts";
// Config values are resolved at connect time, so the literal reference must survive loading.
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference
@ -278,6 +279,35 @@ describe("MCP tools", () => {
expect(second).toMatch(/^mcp__s__a_b_[0-9a-f]{8}$/);
});
it("declares the CallToolResult scripts receive, keeping references in the server's output schema", () => {
const content =
'content: Array<{ data?: string; mimeType?: string; text?: string; type: "text" | "image" | "audio" | "resource_link" | "resource"; [key: string]: unknown; }>; isError?: boolean;';
expect(renderToolOutputType(createMcpResultSchema(undefined) as never)).toBe(
`{ ${content} structuredContent?: { [key: string]: unknown; }; [key: string]: unknown; }`,
);
const hit = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
const schema = createMcpResultSchema({
type: "object",
properties: {
hits: { type: "array", items: { $ref: "#/$defs/Hit~1v1" } },
again: { $ref: "#/properties/hits" },
children: { type: "array", items: { $ref: "#" } },
// A property named like a keyword whose value is data.
default: { $ref: "#/definitions/Hit" },
remote: { $ref: "https://example.com/schema.json" },
},
required: ["hits"],
$defs: { "Hit/v1": { ...hit, default: { $ref: "#/stays" } } },
definitions: { Hit: hit },
}) as unknown as { properties: { structuredContent: { $defs: Record<string, { default: unknown }> } } };
// Data that looks like a reference is kept as is.
expect(schema.properties.structuredContent.$defs["Hit/v1"].default).toEqual({ $ref: "#/stays" });
expect(renderToolOutputType(schema as never)).toBe(
`{ ${content} structuredContent?: { again?: Array<{ id: string; }>; children?: Array<{ again?: Array<{ id: string; }>; children?: Array<unknown>; default?: { id: string; }; hits: Array<{ id: string; }>; remote?: unknown; }>; default?: { id: string; }; hits: Array<{ id: string; }>; remote?: unknown; }; [key: string]: unknown; }`,
);
});
it("converts results, passing the CallToolResult to scripts and flagging errors", async () => {
const blocks = [
{ type: "resource_link" as const, uri: "file:///a", name: "a" },

View file

@ -928,6 +928,24 @@ describe("codemode models", () => {
models.classify(model, { state: {}, questions: { kind: { type: "choice", instructions: "Kind?", criteria: ["a", "b"] } } }),
),
badImage: await attempt(() => models.generateImages({ provider: "scorer", id: "painter" }, { prompt: "a fox" })),
arrayModel: await attempt(() => models.classify(["scorer", "judge"], {})),
arrayContext: await attempt(() => models.classify(model, [])),
arrayState: await attempt(() => models.classify(model, { state: [], questions: ${questions} })),
arrayQuestions: await attempt(() => models.classify(model, { state: {}, questions: [] })),
arrayQuestion: await attempt(() => models.classify(model, { state: {}, questions: { kind: [] } })),
noInstructions: await attempt(() =>
models.classify(model, { state: {}, questions: { kind: { type: "bool", criteria: { true: "y", false: "n" } } } }),
),
badBool: await attempt(() =>
models.classify(model, { state: {}, questions: { kind: { type: "bool", instructions: "Ok?", criteria: ["y", "n"] } } }),
),
badKind: await attempt(() =>
models.classify(model, { state: {}, questions: { kind: { type: "rank", instructions: "Rank?" } } }),
),
arrayImagesContext: await attempt(() => models.generateImages({ provider: "scorer", id: "painter" }, [])),
badImageBlock: await attempt(() =>
models.generateImages({ provider: "scorer", id: "painter" }, { input: [{ type: "text", text: "a fox" }, { type: "image", data: "aW1hZ2U=" }] }),
),
badSplit: await attempt(() => models.getModelOfType("classifier", "scorer/judge")),
};
`,
@ -958,6 +976,30 @@ describe("codemode models", () => {
"models.generateImages() context.input must be a non-empty array of blocks, got undefined.",
);
expect(value.badSplit).toContain("The provider and the id are separate arguments");
expect(value.arrayModel).toContain(
"models.classify() expects a classifier model as its first argument, got an array.",
);
expect(value.arrayContext).toContain(
"models.classify() expects a context object as its second argument, got an empty array.",
);
expect(value.arrayState).toContain("models.classify() context.state must be an object, got an empty array.");
expect(value.arrayQuestions).toContain(
"models.classify() context.questions must map question IDs to questions, got an empty array.",
);
expect(value.arrayQuestion).toContain(
"models.classify() context.questions.kind must be a question object, got an empty array.",
);
expect(value.noInstructions).toContain("models.classify() context.questions.kind.instructions must be a string.");
expect(value.badBool).toContain(
'context.questions.kind is a "bool" question, so criteria must be { true: string, false: string }.',
);
expect(value.badKind).toContain('context.questions.kind.type must be "choice", "score", or "bool", got "rank".');
expect(value.arrayImagesContext).toContain(
"models.generateImages() expects a context object as its second argument, got an empty array.",
);
expect(value.badImageBlock).toContain(
"models.generateImages() context.input[1] must be a text or image block, got { type, data }.",
);
const details = result.details as unknown as CodemodeToolDetails;
expect(details.calls.map((call) => [call.name, call.status, call.error])).toEqual([
["models.classify", "error", "classifier exploded"],

View file

@ -677,6 +677,38 @@ return { docs, sameForAliases: aliases.every((alias) => JSON.stringify(alias) ==
]);
});
it("tells scripts what directly exposed MCP tools resolve to", async () => {
const { harness } = await setup("direct");
harness.session.setActiveToolsByName([...harness.session.getActiveToolNames(), "codemode"]);
const search = harness.session.agent.state.tools.find((tool) => tool.name === "mcp__docs__search");
const fail = harness.session.agent.state.tools.find((tool) => tool.name === "mcp__docs__fail");
expect(search?.description).toContain(
"Codemode: `tools.mcp__docs__search(args)` resolves to `{ content, structuredContent?: { hits }, isError? }`.",
);
expect(fail?.description).toContain(
"Codemode: `tools.mcp__docs__fail(args)` resolves to `{ content, structuredContent?, isError? }`.",
);
});
it("activates another extension's codemode tool when it reaches other tools", async () => {
const otherExec: ExtensionFactory = (pi) => {
pi.registerTool({
name: "codemode",
label: "codemode",
description: "Another extension's codemode tool.",
parameters: Type.Object({}),
defaultActive: false,
reachesTools: () => "all",
execute: async () => ({ content: [], details: undefined }),
});
};
const { harness } = await setup("codemode", undefined, { extensionFactories: [otherExec] });
harness.setResponses([fauxAssistantMessage("ready")]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["codemode"]);
});
it("does not activate another extension's tool named codemode", async () => {
// Registered first, so it wins over the codemode extension's codemode.
const otherExec: ExtensionFactory = (pi) => {
@ -826,7 +858,9 @@ return { docs, sameForAliases: aliases.every((alias) => JSON.stringify(alias) ==
fauxAssistantMessage(
[
fauxToolCall("codemode", {
code: `await tools.mcp__slow_docs__search({ query: "q" }); return (await describeNamespace("slow_docs")).name;`,
code: `await tools.mcp__slow_docs__search({ query: "q" });
const names = ["slow-docs", "slow_docs", "mcp__slow-docs", "mcp__slow_docs"];
return [...new Set(await Promise.all(names.map(async (name) => (await describeNamespace(name))?.name)))];`,
}),
],
{ stopReason: "toolUse" },
@ -837,7 +871,7 @@ return { docs, sameForAliases: aliases.every((alias) => JSON.stringify(alias) ==
await harness.session.prompt("go");
expect(toolResult(harness, "codemode").isError).toBe(false);
expect(getMessageText(toolResult(harness, "codemode")).split("\n").at(-1)).toBe("mcp__slow_docs");
expect(getMessageText(toolResult(harness, "codemode")).split("\n").at(-1)).toBe('["mcp__slow_docs"]');
expect(calls).toEqual(['search:{"query":"q"}']);
});