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:"); }); });