From 1c7e7df765a6fe542d24906756ee981bf7bb84c4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 30 Sep 2026 13:54:02 +0200 Subject: [PATCH] feat(coding-agent): list MCP servers instead of their tools in codemode MCP codemode exposure no longer lists tools, tool counts, or server instructions in the codemode description. Servers are listed by name and an optional mcp.json description; scripts find tools with searchTools() and read instructions with describeNamespace(). codemode-deferred is an alias for codemode. refs #10212 --- packages/coding-agent/CHANGELOG.md | 9 +++ packages/coding-agent/docs/cli.md | 4 +- packages/coding-agent/docs/extensions.md | 4 +- packages/coding-agent/docs/mcp.md | 18 ++--- packages/coding-agent/docs/sdk.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- .../examples/sdk/14-codemode-mcp.ts | 4 +- .../coding-agent/src/core/extensions/types.ts | 7 +- packages/coding-agent/src/core/mcp-servers.ts | 58 +++++++++++----- .../src/extensions/codemode/execute.ts | 30 +++++++- .../src/extensions/codemode/tool.ts | 21 +++--- .../coding-agent/src/extensions/mcp/cli.ts | 7 +- .../coding-agent/src/extensions/mcp/config.ts | 4 +- .../coding-agent/src/extensions/mcp/index.ts | 27 ++++---- .../coding-agent/src/extensions/mcp/tools.ts | 4 +- .../src/extensions/tool-search/tool.ts | 5 +- .../coding-agent/test/mcp-command.test.ts | 3 + .../coding-agent/test/mcp-extension.test.ts | 15 +++- .../test/suite/agent-session-mcp.test.ts | 68 +++++++++++++------ .../coding-agent/test/tool-search.test.ts | 47 +++++++++---- 20 files changed, 234 insertions(+), 105 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3c8850622..1b3358f63 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Added a `description` field for MCP servers (`pi mcp add --description`), shown next to the server in the `codemode` and `tool_search` descriptions, and a `describeNamespace(name)` codemode helper that returns a namespace's instructions and tool names. + +### Changed + +- MCP servers with the default `codemode` exposure no longer list their tools in the `codemode` description; scripts find them with `searchTools()`. `codemode-deferred` is now an alias for `codemode`. Use `direct` exposure for tools the model should see without searching ([#10212](https://github.com/earendil-works/pi/issues/10212)). +- The `codemode` description no longer includes tool counts or MCP server instructions, so it no longer changes when a server's tool list changes. Scripts read server instructions with `describeNamespace()` ([#10212](https://github.com/earendil-works/pi/issues/10212)). + ### Fixed - Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)). diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index a9dcd0c79..1db46d930 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -171,7 +171,7 @@ A script may start with an options line such as `// @options: {"max_output_token While `codemode` is active, `codemode.mode` in [settings](settings.md#tools) decides how the other tools are presented. With `on` (default) declared tools keep being declared and their descriptions show how to call them from scripts. With `only` they are hidden from the model and listed in the `codemode` description instead, so the model calls them through scripts. -The `codemode` description lists the callable tools with their TypeScript declarations, grouped by namespace (for example one MCP server). Declarations share a budget of 3000 estimated tokens (`codemode.inlineBudget` in [settings](settings.md#tools)); every namespace is still listed with its tool count, and the description says whether the list is complete. Scripts find the rest with `await searchTools(query, { limit, namespace })`, which ranks tools with BM25, and `await describeTool(name)`, or by filtering `ALL_TOOLS`. +The `codemode` description lists the callable tools with their TypeScript declarations, grouped by namespace (for example one MCP server). Tools with `deferred` exposure, which includes MCP tools with the default `codemode` exposure, are not listed; their namespace is listed with its description. Declarations share a budget of 3000 estimated tokens (`codemode.inlineBudget` in [settings](settings.md#tools)), and the description says whether the list is complete. Scripts find the rest with `await searchTools(query, { limit, namespace })`, which ranks tools with BM25, and `await describeTool(name)`, or by filtering `ALL_TOOLS`. `await describeNamespace(name)` returns a namespace's description, its instructions (for MCP servers, the server instructions), and the names of its tools. Tools with an output schema resolve to structured values: `bash` to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`, also for non-zero exit codes, and MCP tools to their `CallToolResult`. Other tools resolve to their text output. The `output` of `bash` is not limited to the 2000 lines or 50KB the model sees: it holds up to 1 MiB, and longer output keeps its first and last 512 KiB around an omission marker, with `truncated` set and the full output in `full_output_path`. @@ -324,6 +324,6 @@ These commands work outside a session, so agents can run them through `bash`. Se | `pi mcp login [--timeout ]` | Sign in to an OAuth server: open the authorization page and wait for the browser (default 300 seconds); a terminal also accepts the pasted redirect URL | | `pi mcp logout ` | Delete the stored OAuth credentials of a server | -`add` and `remove` change `~/.pi/agent/mcp.json`, or `.pi/mcp.json` in the current directory with `--local` (`-l`). `add` also takes `--exposure ` (see [Exposure](mcp.md#exposure)) and does not connect; run `pi mcp list` to check the server. +`add` and `remove` change `~/.pi/agent/mcp.json`, or `.pi/mcp.json` in the current directory with `--local` (`-l`). `add` also takes `--exposure ` (see [Exposure](mcp.md#exposure)) and `--description ` and does not connect; run `pi mcp list` to check the server. Project `.pi/mcp.json` files are only read for projects that are already trusted. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index afc64470c..b5ee0d690 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -159,7 +159,7 @@ 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 }` groups related tools, as MCP servers do. Codemode tools list a namespace under one heading. +`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)`. 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`. @@ -187,7 +187,7 @@ Pi records the initial prompt and tool set in the transcript's first system mess ### 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`, `enabled`, and `timeout`. +`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`. ```typescript pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" }); diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index 50d8832a1..ceaca02f6 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -43,7 +43,7 @@ The format matches other MCP clients: "docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }, - "exposure": "direct" + "description": "Search and read the product documentation" } } } @@ -58,6 +58,7 @@ Both server types support: - `timeout`: per-request timeout in seconds (default 60). Progress notifications reset it. - `enabled: false`: keep the entry without connecting to it. - `exposure` and `toolExposure`: control how tools reach the model (see [Control tool exposure](#control-tool-exposure)). +- `description`: what the server offers, in a sentence. The `codemode` and `tool_search` descriptions show it next to the server, so the model knows what to search for. Keep personal servers and servers with credentials in the user-level file. Use the project file only for servers the project requires, and only in trusted projects. @@ -140,13 +141,14 @@ Each server tool is registered as `mcp____`. The server's `exposur | Exposure | Behavior | Typical use | |---|---|---| -| `codemode` (default) | Callable from [`codemode`](cli.md#tools) scripts and listed in its description, but not declared directly to the model. | General MCP servers, especially when scripts should combine or filter calls. | -| `codemode-deferred` | Callable from codemode, but omitted from its inline tool declarations. Scripts find tools with `searchTools()`, `describeTool()`, or `ALL_TOOLS`. | Large, infrequently used servers. | +| `codemode` (default) | Callable from [`codemode`](cli.md#tools) scripts, but neither declared to the model nor listed one by one. The codemode description lists the server with its `description`; scripts find tools with `searchTools()`, `describeTool()`, or `ALL_TOOLS`. | General MCP servers, especially when scripts should combine or filter calls. | | `deferred` | Not declared until [`tool_search`](cli.md#tools) loads a match for the next model call. | Large servers whose tools should be called directly after discovery. | | `direct` | Declared to the model like a built-in tool and also callable from codemode. | Small, frequently used tool sets. | | `hidden` | Registered but unreachable. | Servers or tools that should remain unavailable. | -Pi activates `codemode` when a server with `codemode` or `codemode-deferred` exposure connects. It activates `tool_search` for a server with `deferred` exposure. Codemode declarations share the token budget configured by `codemode.inlineBudget`; scripts can find omitted tools with `searchTools()` or `describeTool()`. +`codemode-deferred` is accepted as an alias for `codemode`. + +Pi activates `codemode` when a server with `codemode` exposure connects. It activates `tool_search` for a server with `deferred` exposure. To make the model see a tool without searching, give it `direct` exposure with `toolExposure`. `toolExposure` overrides the server exposure for individual tools. Keys are exact server tool names or patterns where `*` matches any characters. Exact names win over patterns; among patterns, the first match wins. A server with `hidden` exposure can expose only selected tools: @@ -168,13 +170,13 @@ Pi activates `codemode` when a server with `codemode` or `codemode-deferred` exp `pi mcp list` marks tools whose exposure differs from their server. The Tools view in `/mcp` also shows the effective exposure. -Tools with `codemode`, `codemode-deferred`, or `deferred` exposure can be reached through either indirect mechanism: codemode scripts can call them, and `tool_search` can load them. Codemode calls do not depend on the active tool set, so they remain available after `/tree`, resume, and fork. Tools loaded by `tool_search` are recorded in the transcript and remain declared on that branch. +Tools with `codemode` or `deferred` exposure can be reached through either indirect mechanism: codemode scripts can call them, and `tool_search` can load them. Codemode calls do not depend on the active tool set, so they remain available after `/tree`, resume, and fork. Tools loaded by `tool_search` are recorded in the transcript and remain declared on that branch. To keep `codemode` active without MCP servers, add `"defaultTools": ["+codemode"]` to [settings](settings.md#tools). To prevent automatic codemode activation, set `"autoEnableCodemode": false` beside `mcpServers`. A project value overrides the user-level value. Pi warns once when neither `codemode` nor `tool_search` is active and non-direct tools cannot be called. Text results over 20 KB reach the model with their middle removed around a `…N chars truncated…` marker. The full text is saved to a temporary file named in the result. Codemode scripts receive the complete result and can reduce it before returning output to the model. -Codemode scripts receive the complete MCP `CallToolResult`, including `content`, `structuredContent`, and `isError`. A result with `isError` resolves inside scripts but is reported as an error for direct calls. `image(result.content[0])` forwards an image block. Server instructions are included in the codemode description. +Codemode scripts receive the complete MCP `CallToolResult`, including `content`, `structuredContent`, and `isError`. A result with `isError` resolves inside scripts but is reported as an error for direct calls. `image(result.content[0])` forwards an image block. Server instructions are not part of any tool description; scripts read them with `describeNamespace("mcp__")`, which also returns the server's tool names. ## Use resources @@ -184,7 +186,7 @@ When a connected server offers [resources](https://modelcontextprotocol.io/speci - `list_mcp_resource_templates` lists URI templates for resources the servers do not list directly. - `read_mcp_resource` reads a resource by `server` and `uri`. Text reaches the model as text and images as images. Other binary resources are saved to temporary files, and the model receives the path. Scripts receive `{ server, uri, contents }`. -These tools reach every enabled, non-hidden server with resources. Their exposure is the widest exposure among those servers: `direct`, then `codemode`, `codemode-deferred`, or `deferred`. Resource links in tool results identify `read_mcp_resource` and the server. +These tools reach every enabled, non-hidden server with resources. Their exposure is the widest exposure among those servers: `direct`, then `codemode` or `deferred`. Resource links in tool results identify `read_mcp_resource` and the server. Resources for MCP Apps, identified by `ui://` URIs or `text/html;profile=mcp-app`, are omitted because Pi does not render them. Resource icons are also omitted. @@ -212,4 +214,4 @@ An extension that registers `codemode` or `tool_search` similarly replaces the b ### Use MCP from the SDK -SDK sessions do not load built-in extensions. Add the MCP extension, the codemode extension for `codemode` and `codemode-deferred` servers, and the tool-search extension for `deferred` servers to the resource loader. See [Codemode and MCP](sdk.md#codemode-mcp). +SDK sessions do not load built-in extensions. Add the MCP extension, the codemode extension for `codemode` servers, and the tool-search extension for `deferred` servers to the resource loader. See [Codemode and MCP](sdk.md#codemode-mcp). diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 1ae6b0811..7d7e03ae3 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -113,7 +113,7 @@ Inline extension factories can be supplied through `DefaultResourceLoader`. Give -The CLI loads `codemode`, `tool_search`, and MCP as built-in extensions. SDK sessions do not; add `createCodemodeExtension()`, `createToolSearchExtension()`, and `createMcpExtension()` to the `extensionFactories` of `DefaultResourceLoader`. `codemode` and `tool_search` are registered inactive: enable them through the `defaultTools` setting (`["+codemode", "+tool_search"]` keeps the other default tools), or let the MCP extension activate them: `codemode` for servers with `codemode` or `codemode-deferred` exposure, `tool_search` for servers with `deferred` exposure. The MCP extension connects its servers on `session_start`, so call `session.bindExtensions()`. See [Codemode and MCP](../examples/sdk/14-codemode-mcp.ts). +The CLI loads `codemode`, `tool_search`, and MCP as built-in extensions. SDK sessions do not; add `createCodemodeExtension()`, `createToolSearchExtension()`, and `createMcpExtension()` to the `extensionFactories` of `DefaultResourceLoader`. `codemode` and `tool_search` are registered inactive: enable them through the `defaultTools` setting (`["+codemode", "+tool_search"]` keeps the other default tools), or let the MCP extension activate them: `codemode` for servers with `codemode` exposure, `tool_search` for servers with `deferred` exposure. The MCP extension connects its servers on `session_start`, so call `session.bindExtensions()`. See [Codemode and MCP](../examples/sdk/14-codemode-mcp.ts). See the focused examples for [models](../examples/sdk/02-custom-model.ts), [tools](../examples/sdk/05-tools.ts), [extensions](../examples/sdk/06-extensions.ts), and [full control](../examples/sdk/12-full-control.ts). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index bbbf0b0ab..5666a4da5 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -38,7 +38,7 @@ See [Choose a Model](models.md) for model selection and thinking controls. | Setting | Type | Default | Description | |---|---|---|---| | `defaultTools` | `string[]` | `read`, `bash`, `edit`, `write` | Tools enabled at startup. Plain names replace the defaults; `+name` adds a tool and `-name` removes one. An empty array disables all built-in tools but not extension or SDK tools. | -| `codemode.mode` | `"on"` \| `"only"` | `"on"` | How the `codemode` tool presents tools while it is active. `on`: declared tools get their `codemode` declaration appended to their description, and `codemode` lists only tools that are not declared (MCP `codemode` exposure). `only`: `codemode` lists every tool scripts can call, and active built-in and extension tools are hidden from the model, so it reaches them through `codemode`. | +| `codemode.mode` | `"on"` \| `"only"` | `"on"` | How the `codemode` tool presents tools while it is active. `on`: declared tools get their `codemode` declaration appended to their description, and `codemode` lists only tools that are not declared. `only`: `codemode` lists every tool scripts can call, and active built-in and extension tools are hidden from the model, so it reaches them through `codemode`. | | `codemode.inlineBudget` | number | `3000` | Estimated tokens (characters / 4) the `codemode` tool's description may spend on tool declarations. Tools that do not fit are left out and found with `searchTools()`. `0` lists only namespaces. | Available built-in tools are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`. `defaultTools` can also name `codemode` and `tool_search`, which built-in extensions register inactive, and other extension tools registered inactive. diff --git a/packages/coding-agent/examples/sdk/14-codemode-mcp.ts b/packages/coding-agent/examples/sdk/14-codemode-mcp.ts index 1bb829ee0..9559eb988 100644 --- a/packages/coding-agent/examples/sdk/14-codemode-mcp.ts +++ b/packages/coding-agent/examples/sdk/14-codemode-mcp.ts @@ -6,8 +6,8 @@ * * `codemode` and `tool_search` are registered inactive. Enable them through the `defaultTools` setting * (`tools` would also restrict the session to the named tools, which hides MCP tools), or let the - * MCP extension activate them: `codemode` for servers with `codemode` or `codemode-deferred` exposure, - * `tool_search` for servers with `deferred` exposure. + * MCP extension activate them: `codemode` for servers with `codemode` exposure, `tool_search` for + * servers with `deferred` exposure. */ import { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ddbaa8464..3e0ea47ba 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -527,8 +527,13 @@ export interface ToolAnnotations { export interface ToolNamespace { /** For example `mcp__docs`. */ name: string; - /** Shown once above the group's tools. */ + /** Short summary shown once with the group in model-facing tool listings. */ description?: string; + /** + * Longer usage guidance, such as MCP server instructions. Not part of tool listings; tools that + * describe the namespace on request (codemode's `describeNamespace()`) return it. + */ + instructions?: string; } /** The tools of a session as {@link ToolDefinition.prepareLoadout} sees them. */ diff --git a/packages/coding-agent/src/core/mcp-servers.ts b/packages/coding-agent/src/core/mcp-servers.ts index b10ab075c..01a6afb80 100644 --- a/packages/coding-agent/src/core/mcp-servers.ts +++ b/packages/coding-agent/src/core/mcp-servers.ts @@ -6,28 +6,29 @@ */ /** - * - `codemode`: tools are callable from codemode scripts and listed in its description, but not - * declared to the model. - * - `codemode-deferred`: like `codemode`, but not listed in the codemode description. Scripts find - * them with `searchTools()`. + * - `codemode`: tools are callable from codemode scripts but neither declared to the model nor + * listed in the codemode description, which lists only the server's namespace. Scripts find them + * with `searchTools()`. `codemode-deferred` is accepted as an alias. * - `deferred`: not declared to the model until the `tool_search` tool loads them; the model then * calls them directly. Does not need codemode. * - `direct`: tools are declared to the model like any other tool (and callable from codemode). * - `hidden`: tools are registered but unreachable. */ -export type McpExposure = "codemode" | "codemode-deferred" | "deferred" | "direct" | "hidden"; +export type McpExposure = "codemode" | "deferred" | "direct" | "hidden"; -const MCP_EXPOSURES: readonly string[] = [ - "codemode", - "codemode-deferred", - "deferred", - "direct", - "hidden", -] satisfies McpExposure[]; +const MCP_EXPOSURES: readonly string[] = ["codemode", "deferred", "direct", "hidden"] satisfies McpExposure[]; + +/** Older exposure names, accepted in configs and replaced by their current name when validated. */ +const MCP_EXPOSURE_ALIASES: Readonly> = { "codemode-deferred": "codemode" }; interface McpServerConfigBase { /** Default: `codemode`. */ exposure?: McpExposure; + /** + * What the server offers, in a sentence. The codemode and `tool_search` descriptions show it next to + * the server's namespace, so the model knows what to search for without connecting first. + */ + description?: string; /** * Exposure of single tools, overriding `exposure`. Keys are tool names as the server offers them, * or patterns where `*` matches any characters. An exact name wins over patterns; among patterns @@ -129,6 +130,24 @@ function isExposure(value: unknown): value is McpExposure { return typeof value === "string" && MCP_EXPOSURES.includes(value); } +/** The exposure an alias stands for; other values are returned unchanged. */ +function resolveExposureAlias(value: unknown): unknown { + return typeof value === "string" ? (MCP_EXPOSURE_ALIASES[value] ?? value) : value; +} + +/** A copy of the server entry with exposure aliases replaced by their current names. */ +function resolveExposureAliases(value: Record): Record { + const { exposure, toolExposure } = value; + const resolved: Record = { ...value }; + if (exposure !== undefined) resolved.exposure = resolveExposureAlias(exposure); + if (isRecord(toolExposure)) { + resolved.toolExposure = Object.fromEntries( + Object.entries(toolExposure).map(([tool, entry]) => [tool, resolveExposureAlias(entry)]), + ); + } + return resolved; +} + function toolPatternRegExp(pattern: string): RegExp { const source = pattern .split("*") @@ -148,11 +167,15 @@ export function getMcpToolExposure(config: McpServerConfig, toolName: string): M return config.exposure ?? "codemode"; } -/** Validate one server entry of the `mcpServers` shape. Returns the config or an error message. */ -export function validateMcpServerConfig(name: string, value: unknown): McpServerConfig | string { +/** + * Validate one server entry of the `mcpServers` shape. Returns a copy of the config with exposure + * aliases resolved, or an error message. + */ +export function validateMcpServerConfig(name: string, raw: unknown): McpServerConfig | string { if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`; - if (!isRecord(value)) return `server "${name}" must be an object`; - const { type, exposure, enabled, timeout, toolExposure } = value; + if (!isRecord(raw)) return `server "${name}" must be an object`; + const value = resolveExposureAliases(raw); + const { type, exposure, enabled, timeout, toolExposure, description } = value; const exposures = MCP_EXPOSURES.map((value) => `"${value}"`).join(", "); if (exposure !== undefined && !isExposure(exposure)) { return `server "${name}": exposure must be one of ${exposures}`; @@ -164,6 +187,9 @@ export function validateMcpServerConfig(name: string, value: unknown): McpServer } } if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`; + if (description !== undefined && typeof description !== "string") { + return `server "${name}": description must be a string`; + } if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) { return `server "${name}": timeout must be a positive number of seconds`; } diff --git a/packages/coding-agent/src/extensions/codemode/execute.ts b/packages/coding-agent/src/extensions/codemode/execute.ts index d8d3dff89..f5781fe32 100644 --- a/packages/coding-agent/src/extensions/codemode/execute.ts +++ b/packages/coding-agent/src/extensions/codemode/execute.ts @@ -19,7 +19,7 @@ import { toCodemodeIdentifier, } from "@earendil-works/pi-codemode"; import { getCodemodeWorkerSpecifier, getQuickJSWasmPath } from "../../config.ts"; -import type { ExtensionToolContext } from "../../core/extensions/types.ts"; +import type { ExtensionToolContext, ToolNamespace } from "../../core/extensions/types.ts"; import type { SessionEntry } from "../../core/session-manager.ts"; import { combineUsage } from "../../core/usage-totals.ts"; import { Bm25Ranker, createToolSearchDocument, DEFAULT_TOOL_SEARCH_LIMIT } from "../tool-search/tool.ts"; @@ -319,7 +319,10 @@ export async function executeCodemode( }; } -/** `searchTools()` and `describeTool()`: ranked search and lookup over the script's nested tools. */ +/** + * `searchTools()`, `describeTool()`, and `describeNamespace()`: ranked search and lookup over the + * script's nested tools and their namespaces. + */ function createDiscoveryGlobals( tools: readonly AgentTool[], samples: ReadonlyMap, @@ -362,6 +365,29 @@ function createDiscoveryGlobals( return tool ? samples.get(tool.name) : undefined; }, }, + { + name: "describeNamespace", + spread: true, + execute: (args) => { + const [name] = args as unknown[]; + if (typeof name !== "string") throw new Error("describeNamespace() expects a namespace name"); + let namespace: ToolNamespace | undefined; + const names: string[] = []; + for (const tool of tools) { + const toolNamespace = options.getToolNamespace?.(tool.name); + if (toolNamespace?.name !== name) continue; + namespace ??= toolNamespace; + names.push(toCodemodeIdentifier(tool.name)); + } + if (!namespace) return undefined; + return { + name, + ...(namespace.description ? { description: namespace.description } : {}), + ...(namespace.instructions ? { instructions: namespace.instructions } : {}), + tools: names, + }; + }, + }, ]; } diff --git a/packages/coding-agent/src/extensions/codemode/tool.ts b/packages/coding-agent/src/extensions/codemode/tool.ts index 831237107..06e9a4e00 100644 --- a/packages/coding-agent/src/extensions/codemode/tool.ts +++ b/packages/coding-agent/src/extensions/codemode/tool.ts @@ -152,6 +152,7 @@ const DESCRIPTION_INTRO = `Run JavaScript code to orchestrate/compose tool calls - \`ALL_TOOLS\`: metadata for the enabled nested tools as \`{ name, description }\` entries. - \`searchTools(query: string, options?: { limit?: number; namespace?: string })\`: resolves to the nested tools that best match the query (BM25, default limit 8), as \`{ name, description }\` entries like \`ALL_TOOLS\`. - \`describeTool(name: string)\`: resolves to the description and declaration of a nested tool, or \`undefined\`. +- \`describeNamespace(name: string)\`: resolves to \`{ name, description?, instructions?, tools }\` for a namespace of nested tools, such as an MCP server: its usage instructions and the names of its tools, or \`undefined\`. - \`console.log(...)\` and the other \`console\` methods append a text item like \`text()\`. - \`return value\` at the top level appends the value like \`text()\`.`; @@ -217,7 +218,7 @@ export const MODEL_GLOBAL_DECLARATIONS: readonly Omit[] ]; const DEFERRED_TOOLS_GUIDANCE = `Some deferred nested tools may be omitted from this description. They are still available on the global \`tools\` object and listed in \`ALL_TOOLS\`. -To find one, call \`await searchTools(query)\`, or filter \`ALL_TOOLS\` by \`name\` and \`description\`.`; +To find one, call \`await searchTools(query)\` (pass \`{ namespace }\` to search one namespace), or filter \`ALL_TOOLS\` by \`name\` and \`description\`. \`await describeNamespace(name)\` returns a namespace's usage instructions and the names of its tools.`; /** Default for {@link CodemodeDescriptionOptions.inlineBudget}, in estimated tokens. */ export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000; @@ -301,8 +302,9 @@ function selectCatalog(groups: readonly CatalogGroup[], budget: number | undefin /** * Model-facing description: the helper list, guidance for omitted tools, the shared MCP types when * MCP tools are callable, the `models` API, and one section per tool, grouped by namespace. Tool - * sections are limited to `inlineBudget`; every namespace is listed with its tool count either - * way, and the listing states whether it is complete. + * sections are limited to `inlineBudget`; every namespace is listed with its description either + * way, and the listing states whether it is complete. It carries no tool counts or namespace + * instructions, so it stays the same when a server's tool list changes. */ export function createCodemodeDescription( tools: readonly AgentTool[], @@ -345,21 +347,20 @@ export function createCodemodeDescription( const toolSections = [ complete - ? `Nested tools: COMPLETE list (${declarations.length} tool${declarations.length === 1 ? "" : "s"}).` - : `Nested tools: PARTIAL - ${shown.size} of ${declarations.length} shown.`, + ? "Nested tools: COMPLETE list." + : "Nested tools: PARTIAL list. Find the tools that are not listed with `searchTools()`.", ]; for (const { namespace, entries } of ordered) { const visible = entries.filter((entry) => shown.has(entry.name)); if (namespace) { - const count = `${entries.length} tool${entries.length === 1 ? "" : "s"}`; - const suffix = + const listing = visible.length === entries.length ? "" : visible.length === 0 - ? ", none shown" - : `, ${visible.length} shown`; + ? " (tools not listed)" + : " (some tools not listed)"; const description = namespace.description?.trim(); - toolSections.push(`## ${namespace.name} (${count}${suffix})${description ? `\n${description}` : ""}`); + toolSections.push(`## ${namespace.name}${listing}${description ? `\n${description}` : ""}`); } for (const entry of visible) toolSections.push(entry.section); } diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index dafd08736..48aabf4c2 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -63,8 +63,8 @@ Options for add: OAuth client secret (may be \${NAME} or !command) --oauth-callback-port Fixed OAuth callback port - --exposure codemode (default), codemode-deferred, deferred, direct, - or hidden + --exposure codemode (default), deferred, direct, or hidden + --description What the server offers, shown to the model with its tools Other options: --json Print the list as JSON @@ -291,6 +291,7 @@ function add( "oauth-client-secret": "value", "oauth-callback-port": "value", exposure: "value", + description: "value", }, error, 2, @@ -347,6 +348,8 @@ function add( }; } if (exposure !== undefined) config.exposure = exposure; + const description = value("description"); + if (description !== undefined) config.description = description; const validated = validateMcpServerConfig(name, config); if (typeof validated === "string") { error(validated); diff --git a/packages/coding-agent/src/extensions/mcp/config.ts b/packages/coding-agent/src/extensions/mcp/config.ts index a633b0edc..b2da3cd6f 100644 --- a/packages/coding-agent/src/extensions/mcp/config.ts +++ b/packages/coding-agent/src/extensions/mcp/config.ts @@ -19,7 +19,7 @@ * HTTP servers without an `Authorization` header use OAuth when they answer 401 (sign in with `/mcp`). * * The top-level `autoEnableCodemode` (default true) activates the codemode tool when a server - * with `codemode` or `codemode-deferred` exposure connects. A project value overrides the global one. + * with `codemode` exposure connects. A project value overrides the global one. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -50,7 +50,7 @@ export interface McpServerEntry { export interface LoadedMcpConfig { servers: McpServerEntry[]; - /** Activate the codemode tool when `codemode` or `codemode-deferred` servers connect. Default: true. */ + /** Activate the codemode tool when `codemode` servers connect. Default: true. */ autoEnableCodemode?: boolean; errors: string[]; } diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 856249943..900f1dc12 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -5,10 +5,12 @@ * `pi.registerMcpServer()` when a session starts, and servers registered later right away. A server * in `mcp.json` takes precedence over a registered server of the same name. Tools are registered as * `mcp____`. By default (`"exposure": "codemode"`) the tools are only callable from - * codemode scripts, which keeps large MCP tool lists out of the model's tool declarations; the - * codemode tool is activated for that unless `autoEnableCodemode` is false. `"codemode-deferred"` - * leaves them out of the codemode description as well. `"deferred"` declares them to the model once - * the `tool_search` tool loads them, and activates `tool_search` instead of codemode. + * codemode scripts, which keeps MCP tool lists out of the model's tool declarations and the codemode + * description: the description lists the server's namespace with its configured `description`, and + * scripts find the tools with `searchTools()` and the server instructions with + * `describeNamespace()`. The codemode tool is activated for that unless `autoEnableCodemode` is + * false. `"deferred"` declares the tools to the model once the `tool_search` tool loads them, and + * activates `tool_search` instead of codemode. * `"exposure": "direct"` declares them to the model right away, and `"hidden"` makes them * unreachable. `toolExposure` overrides the exposure of single tools. Servers with resources are * reached through Codex's `list_mcp_resources`, `list_mcp_resource_templates`, and @@ -89,8 +91,7 @@ interface McpServer { } const EXPOSURE_DESCRIPTIONS: Record, string> = { - codemode: "called from codemode scripts, listed in the codemode description", - "codemode-deferred": "called from codemode scripts, not listed; scripts find them with searchTools()", + codemode: "called from codemode scripts, which find them with searchTools()", deferred: "not declared until tool_search loads them, then called directly; no codemode needed", direct: "declared to the model like built-in tools", }; @@ -239,9 +240,11 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension const server = connection.entry.name; const entry = findServer(server)?.entry ?? connection.entry; const namespaceName = `mcp__${server}`; + const description = entry.config.description?.trim(); const namespace = { name: namespaceName, - description: connection.instructions ?? `Tools in the ${namespaceName} namespace.`, + ...(description ? { description } : {}), + ...(connection.instructions ? { instructions: connection.instructions } : {}), }; const previous = serverTools.get(server) ?? new Set(); const current = new Set(); @@ -304,9 +307,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension */ const syncResourceTools = () => { const exposures = new Set(serversWithResources().map((server) => exposureOf(server.entry))); - const exposure = (["direct", "codemode", "codemode-deferred", "deferred"] as const).find((candidate) => - exposures.has(candidate), - ); + const exposure = (["direct", "codemode", "deferred"] as const).find((candidate) => exposures.has(candidate)); const next = exposure ?? "hidden"; if (next === resourceToolsExposure || (resourceToolsExposure === undefined && next === "hidden")) return; const wasDirect = resourceToolsExposure === "direct"; @@ -322,8 +323,8 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension /** * Tools that are not declared to the model are reached through the codemode tool (scripts call * them) or the tool_search tool (it declares them). Either reaches every such tool. Activate the - * one the tools' exposure asks for: codemode for `codemode` and `codemode-deferred` unless - * `autoEnableCodemode` is false, tool_search for `deferred`. + * one the tools' exposure asks for: codemode for `codemode` unless `autoEnableCodemode` is false, + * tool_search for `deferred`. */ const ensureDiscoveryActive = (ctx: ExtensionContext) => { const exposures = new Set(); @@ -333,7 +334,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension if (connection.hasResources) exposures.add(exposureOf(entry)); for (const tool of connection.tools) exposures.add(getMcpToolExposure(entry.config, tool.name)); } - const needsCodemode = exposures.has("codemode") || exposures.has("codemode-deferred"); + 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. diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts index d556f9a02..650fe02a7 100644 --- a/packages/coding-agent/src/extensions/mcp/tools.ts +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -32,11 +32,11 @@ import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts" import type { McpExposure } from "./config.ts"; /** - * Tool exposure of an MCP exposure. Both deferred exposures leave tools out of the codemode + * 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: McpExposure): ToolExposure { - return exposure === "codemode-deferred" ? "deferred" : exposure; + return exposure === "codemode" ? "deferred" : exposure; } /** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */ diff --git a/packages/coding-agent/src/extensions/tool-search/tool.ts b/packages/coding-agent/src/extensions/tool-search/tool.ts index a2c097f1a..a2cee3986 100644 --- a/packages/coding-agent/src/extensions/tool-search/tool.ts +++ b/packages/coding-agent/src/extensions/tool-search/tool.ts @@ -102,7 +102,8 @@ function schemaText(schema: unknown, parts: string[]): void { /** * Search text of a tool: the name, the name with `_` - * as spaces, the description, schema descriptions and property names, and the namespace. + * as spaces, the description, schema descriptions and property names, and the namespace with its + * description and instructions. */ export function createToolSearchDocument( tool: Pick, @@ -110,7 +111,7 @@ export function createToolSearchDocument( ): ToolSearchDocument { const parts = [tool.name, tool.name.replaceAll("_", " "), tool.description]; schemaText(tool.parameters, parts); - if (namespace) parts.push(namespace.name, namespace.description ?? ""); + if (namespace) parts.push(namespace.name, namespace.description ?? "", namespace.instructions ?? ""); return { name: tool.name, text: parts.filter((part) => part.trim()).join(" ") }; } diff --git a/packages/coding-agent/test/mcp-command.test.ts b/packages/coding-agent/test/mcp-command.test.ts index 78fe958c5..bfcec881d 100644 --- a/packages/coding-agent/test/mcp-command.test.ts +++ b/packages/coding-agent/test/mcp-command.test.ts @@ -108,6 +108,8 @@ describe("pi mcp", () => { "X-Team=core", "--exposure", "direct", + "--description", + "Product docs", ], { fixture: servers.fixture }, ); @@ -121,6 +123,7 @@ describe("pi mcp", () => { // biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference headers: { "X-Team": "core", Authorization: "Bearer ${DOCS_TOKEN}" }, exposure: "direct", + description: "Product docs", }, }, }); diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 09bfe11b1..3750f8fd7 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -83,9 +83,12 @@ describe("MCP config", () => { autoEnableCodemode: false, mcpServers: { later: { command: "x", exposure: "deferred" }, - scripts: { command: "x", exposure: "codemode-deferred" }, + // `codemode-deferred` is an alias for `codemode`. + scripts: { command: "x", exposure: "codemode-deferred", toolExposure: { a: "codemode-deferred" } }, off: { command: "x", exposure: "hidden" }, wrong: { command: "x", exposure: "model-only" }, + described: { command: "x", description: "Docs search" }, + badDescription: { command: "x", description: 1 }, }, }, { autoEnableCodemode: "yes", mcpServers: {} }, @@ -95,10 +98,16 @@ describe("MCP config", () => { expect(untrusted.autoEnableCodemode).toBe(false); expect(untrusted.servers.map((server) => [server.name, server.config.exposure])).toEqual([ ["later", "deferred"], - ["scripts", "codemode-deferred"], + ["scripts", "codemode"], ["off", "hidden"], + ["described", undefined], + ]); + expect(untrusted.servers[1].config.toolExposure).toEqual({ a: "codemode" }); + expect(untrusted.servers[3].config.description).toBe("Docs search"); + expect(untrusted.errors).toEqual([ + expect.stringContaining('server "wrong": exposure must be one of'), + expect.stringContaining('server "badDescription": description must be a string'), ]); - expect(untrusted.errors).toEqual([expect.stringContaining('server "wrong": exposure must be one of')]); const trusted = loadMcpConfig({ ...paths, projectTrusted: true }); expect(trusted.autoEnableCodemode).toBe(false); diff --git a/packages/coding-agent/test/suite/agent-session-mcp.test.ts b/packages/coding-agent/test/suite/agent-session-mcp.test.ts index fd0197d38..b75820882 100644 --- a/packages/coding-agent/test/suite/agent-session-mcp.test.ts +++ b/packages/coding-agent/test/suite/agent-session-mcp.test.ts @@ -43,7 +43,12 @@ const SERVER_TOOLS = [ ]; /** Minimal MCP server over an in-memory transport. Records the tool calls it receives. */ -function createFakeServer(calls: string[], listTools: () => unknown[] = () => SERVER_TOOLS, resources = false) { +function createFakeServer( + calls: string[], + listTools: () => unknown[] = () => SERVER_TOOLS, + resources = false, + instructions?: string, +) { const pair = createInMemoryTransportPair(); const respond = (request: JsonRpcRequest): unknown => { switch (request.method) { @@ -52,6 +57,7 @@ function createFakeServer(calls: string[], listTools: () => unknown[] = () => SE protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: { tools: {}, ...(resources ? { resources: {} } : {}) }, serverInfo: { name: "docs", version: "1.0.0" }, + ...(instructions ? { instructions } : {}), }; case "tools/list": return { tools: listTools() }; @@ -117,6 +123,8 @@ describe("AgentSession MCP integration", () => { toolExposure?: Record; resources?: boolean; withoutToolSearch?: boolean; + description?: string; + instructions?: string; } = {}, ) { const { @@ -125,6 +133,8 @@ describe("AgentSession MCP integration", () => { toolExposure, resources, withoutToolSearch, + description, + instructions, ...configOptions } = options; const calls: string[] = []; @@ -132,7 +142,12 @@ describe("AgentSession MCP integration", () => { const servers: ReturnType["server"][] = []; const entry: McpServerEntry = { name: "docs", - config: { url: "http://unused.invalid", exposure, ...(toolExposure ? { toolExposure } : {}) }, + config: { + url: "http://unused.invalid", + exposure, + ...(toolExposure ? { toolExposure } : {}), + ...(description ? { description } : {}), + }, source: "test", }; // `builtInTools` are the built-in tools active at the start. The MCP extension activates codemode @@ -146,7 +161,7 @@ describe("AgentSession MCP integration", () => { createMcpExtension({ loadConfig: () => ({ servers: [entry], errors: [], ...configOptions }), createTransport: () => { - const pair = createFakeServer(calls, listTools, resources); + const pair = createFakeServer(calls, listTools, resources, instructions); servers.push(pair.server); void pair.server.start(); return pair.client; @@ -207,9 +222,11 @@ describe("AgentSession MCP integration", () => { expect(harness.session.getActiveToolNames()).toEqual(["codemode"]); expect(declaredToolNames(harness)).toEqual(["codemode"]); expect(nestedToolNames(harness)).toEqual([searchName, "mcp__docs__fail", "mcp__docs__shot"]); + // The description lists the server, not its tools. const codemode = harness.session.agent.state.tools.find((tool) => tool.name === "codemode"); expect(codemode?.description).toContain("Shared MCP Types:"); - expect(codemode?.description).toContain("### `mcp__docs__search`"); + expect(codemode?.description).toContain("## mcp__docs (tools not listed)"); + expect(codemode?.description).not.toContain(searchName); const result = toolResult(harness, "codemode"); expect(result.isError).toBe(false); @@ -243,7 +260,7 @@ describe("AgentSession MCP integration", () => { expect(harness.session.getActiveToolNames()).toEqual(["codemode"]); expect(nestedToolNames(harness)).toContain(searchName); const codemode = harness.session.agent.state.tools.find((tool) => tool.name === "codemode"); - expect(codemode?.description).toContain(`${searchName}(args: {`); + expect(codemode?.description).toContain("## mcp__docs (tools not listed)"); }); it("rejects direct model calls to codemode-only MCP tools", async () => { @@ -388,36 +405,45 @@ return { uris: listed.resources.map((r) => r.uri), text: read.contents[0].text } expect(declaredToolNames(harness)).toEqual(["mcp__docs__search", "codemode"]); expect(nestedToolNames(harness)).toEqual(["mcp__docs__search", "mcp__docs__shot"]); const codemode = harness.session.agent.state.tools.find((tool) => tool.name === "codemode"); - expect(codemode?.description).toContain("### `mcp__docs__shot`"); + expect(codemode?.description).toContain("## mcp__docs (tools not listed)"); + expect(codemode?.description).not.toContain("mcp__docs__shot"); expect(codemode?.description).not.toContain("mcp__docs__fail"); }); - it("keeps codemode-deferred MCP tools callable from codemode but out of its description", async () => { - const { harness, calls } = await setup("codemode-deferred"); - const searchName = createMcpToolName("docs", "search"); + it("describes the server with its configured description and returns its instructions to scripts", async () => { + const { harness } = await setup("codemode", undefined, { + description: "Search the product docs", + instructions: "Always search before reading.", + builtInTools: ["tool_search"], + }); harness.setResponses([ fauxAssistantMessage( [ fauxToolCall("codemode", { - code: `return (await tools.${searchName}({ query: "d" })).structuredContent;`, + code: `return { docs: await describeNamespace("mcp__docs"), none: await describeNamespace("mcp__nope") };`, }), ], - { - stopReason: "toolUse", - }, + { stopReason: "toolUse" }, ), fauxAssistantMessage("done"), ]); await harness.session.prompt("go"); - expect(harness.session.getActiveToolNames()).toEqual(["codemode"]); - const codemode = harness.session.agent.state.tools.find((tool) => tool.name === "codemode"); - expect(codemode?.description).not.toContain(searchName); - const result = toolResult(harness, "codemode"); - expect(result.isError).toBe(false); - expect(JSON.parse((result.content[1] as { text: string }).text)).toEqual({ hits: ["d guide", "d faq"] }); - expect(calls).toEqual(['search:{"query":"d"}']); + // The instructions stay out of every tool description. + const description = (name: string) => + harness.session.agent.state.tools.find((tool) => tool.name === name)?.description ?? ""; + expect(description("codemode")).toContain("## mcp__docs (tools not listed)\nSearch the product docs"); + expect(description("tool_search")).toContain("- mcp__docs: Search the product docs"); + for (const name of ["codemode", "tool_search"]) expect(description(name)).not.toContain("Always search"); + expect(JSON.parse(getMessageText(toolResult(harness, "codemode")).split("\n").at(-1) ?? "")).toEqual({ + docs: { + name: "mcp__docs", + description: "Search the product docs", + instructions: "Always search before reading.", + tools: ["mcp__docs__search", "mcp__docs__fail", "mcp__docs__shot"], + }, + }); }); it("does not activate codemode when autoEnableCodemode is false", async () => { @@ -532,7 +558,7 @@ return { uris: listed.resources.map((r) => r.uri), text: read.contents[0].text } }); it("finds tools from scripts with searchTools() and describeTool()", async () => { - const { harness, calls } = await setup("codemode-deferred"); + const { harness, calls } = await setup("codemode"); harness.setResponses([ fauxAssistantMessage( [ diff --git a/packages/coding-agent/test/tool-search.test.ts b/packages/coding-agent/test/tool-search.test.ts index 297838312..d5533bb05 100644 --- a/packages/coding-agent/test/tool-search.test.ts +++ b/packages/coding-agent/test/tool-search.test.ts @@ -92,41 +92,58 @@ describe("codemode description catalog", () => { it("lists everything without a budget", () => { const description = createCodemodeDescription(all, { namespaces }); - expect(description).toContain("Nested tools: COMPLETE list (6 tools)."); - expect(description).toContain("## mcp__github (3 tools)\nGitHub server"); - expect(description).toContain("## mcp__docs (2 tools)\n\n### `mcp__docs"); + expect(description).toContain("Nested tools: COMPLETE list."); + expect(description).toContain("## mcp__github\nGitHub server"); + expect(description).toContain("## mcp__docs\n\n### `mcp__docs"); expect(description).not.toContain("Some deferred nested tools"); }); it("fills the budget round-robin, cheapest first, and says what is missing", () => { // Each small section costs about 42 tokens: one tool per group, then one more. const description = createCodemodeDescription(all, { namespaces, inlineBudget: 170 }); - expect(description).toContain("Nested tools: PARTIAL - 4 of 6 shown."); + expect(description).toContain("Nested tools: PARTIAL list."); expect(description).toContain("### `read_notes`"); - expect(description).toContain("## mcp__docs (2 tools, 1 shown)"); + expect(description).toContain("## mcp__docs (some tools not listed)"); expect(description).toContain("### `mcp__docs__search`"); expect(description).not.toContain("### `mcp__docs__long`"); - expect(description).toContain("## mcp__github (3 tools, 2 shown)"); + expect(description).toContain("## mcp__github (some tools not listed)"); expect(description).toContain("To find one, call `await searchTools(query)`"); // Deterministic: the same input gives the same description. expect(createCodemodeDescription(all, { namespaces, inlineBudget: 170 })).toBe(description); }); - it("never lists deferred tools and still counts them", () => { - const description = createCodemodeDescription(all, { - namespaces, - deferred: new Set(github.map((entry) => entry.name)), - }); - expect(description).toContain("Nested tools: PARTIAL - 3 of 6 shown."); - expect(description).toContain("## mcp__github (3 tools, none shown)\nGitHub server"); + it("never lists deferred tools or counts them", () => { + const deferred = new Set(github.map((entry) => entry.name)); + const description = createCodemodeDescription(all, { namespaces, deferred }); + expect(description).toContain("Nested tools: PARTIAL list."); + expect(description).toContain("## mcp__github (tools not listed)\nGitHub server"); expect(description).not.toContain("### `mcp__github__a`"); expect(description).toContain("Some deferred nested tools may be omitted from this description."); + // A server adding a deferred tool does not change the description. + const added = tool("mcp__github__d", "GitHub d."); + const withAdded = new Map([...namespaces, [added.name, { name: "mcp__github", description: "GitHub server" }]]); + expect( + createCodemodeDescription([...all, added], { + namespaces: withAdded, + deferred: new Set([...deferred, added.name]), + }), + ).toBe(description); + }); + + it("leaves namespace instructions out", () => { + const description = createCodemodeDescription(github, { + namespaces: new Map( + github.map((entry) => [entry.name, { name: "mcp__github", instructions: "Long usage guide." }] as const), + ), + }); + expect(description).toContain("## mcp__github\n\n### `mcp__github__a`"); + expect(description).not.toContain("Long usage guide."); }); it("lists only namespaces with a zero budget", () => { const description = createCodemodeDescription(all, { namespaces, inlineBudget: 0 }); - expect(description).toContain("Nested tools: PARTIAL - 0 of 6 shown."); - expect(description).toContain("## mcp__docs (2 tools, none shown)"); + expect(description).toContain("Nested tools: PARTIAL list."); + expect(description).toContain("## mcp__docs (tools not listed)"); expect(description).not.toContain("codemode tool declaration:"); }); });