diff --git a/package-lock.json b/package-lock.json index 78bc9105d..80c28baa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5641,6 +5641,7 @@ "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-codemode": "^0.87.1", + "@earendil-works/pi-mcp": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", @@ -5789,7 +5790,7 @@ }, "packages/mcp": { "name": "@earendil-works/pi-mcp", - "version": "0.85.1", + "version": "0.87.1", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 5669f2b0f..e44086909 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -326,10 +326,11 @@ async function runLoop( * Declare tool loadout changes to the model. * * `context.tools` is what the runtime can execute; the transcript's system messages declare - * what the model may call. Before each request the difference becomes `toolsAdded` and - * `toolsRemoved` on a system message. When a pending system message exists, its tool fields - * are treated as intent and replaced with the delta between the committed transcript and - * the executable set, so replay always yields exactly `context.tools`. Otherwise a new + * what the model may call, which is every tool except those marked `nestedOnly`. Before each + * request the difference becomes `toolsAdded` and `toolsRemoved` on a system message. When a + * pending system message exists, its tool fields are treated as intent and replaced with the + * delta between the committed transcript and the declared set, so replay always yields exactly + * the declared tools. Otherwise a new * system message is inserted before the first non-system pending message. */ function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] { @@ -348,7 +349,7 @@ function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage : pendingMessages; const changes = getToolStateChanges( getCurrentTools([...context.messages, ...baseline]), - (context.tools ?? []).map(toToolDeclaration), + (context.tools ?? []).filter((tool) => !tool.nestedOnly).map(toToolDeclaration), ); const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0; @@ -724,7 +725,7 @@ async function prepareToolCall( parentToolCall?: AgentToolCall, ): Promise { const tool = currentContext.tools?.find((t) => t.name === toolCall.name); - if (!tool) { + if (!tool || (tool.nestedOnly && !parentToolCall)) { return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index dcefaf63b..3c3473ab1 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -517,6 +517,12 @@ export interface AgentTool Promise>; /** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */ replay?: "never" | "safe"; + /** + * Only callable by other tools through {@link AgentToolContext.executeTool}. The tool is not + * declared to the model, and a model-issued call to it fails as an unknown tool. Use it for + * tools that should only be reached through an orchestrating tool such as codemode. + */ + nestedOnly?: boolean; /** * Per-tool execution mode override. * - "sequential": this tool must execute one at a time with other tool calls. @@ -531,7 +537,7 @@ export interface AgentTool[]; } diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 754a978a4..bbf9d89a6 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -2243,3 +2243,74 @@ describe("nested tool calls", () => { expect(seen).toEqual([undefined, { secret: 2 }]); }); }); + +describe("nestedOnly tools", () => { + it("hides nested-only tools from the model but lets other tools call them", async () => { + const schema = Type.Object({}); + const hidden: AgentTool = { + name: "hidden", + label: "Hidden", + description: "Only reachable from other tools", + parameters: schema, + nestedOnly: true, + async execute() { + return { content: [{ type: "text", text: "secret" }], details: {} }; + }, + }; + let nestedText = ""; + const runner: AgentTool = { + name: "runner", + label: "Runner", + description: "Runs other tools", + parameters: schema, + async execute(_id, _params, _signal, _onUpdate, context) { + const outcome = await context?.executeTool("hidden", {}); + nestedText = outcome?.result.content[0]?.type === "text" ? outcome.result.content[0].text : ""; + return { content: [], details: {} }; + }, + }; + + const declaredTools: string[][] = []; + let callIndex = 0; + const streamFn = (_model: Model, context: { messages: Message[] }) => { + for (const message of context.messages) { + if (message.role === "system" && message.toolsAdded) { + declaredTools.push(message.toolsAdded.map((tool) => tool.name)); + } + } + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const content: AssistantMessage["content"] = + callIndex === 0 + ? [ + { type: "toolCall", id: "direct", name: "hidden", arguments: {} }, + { type: "toolCall", id: "nested", name: "runner", arguments: {} }, + ] + : [{ type: "text", text: "done" }]; + const stopReason = callIndex === 0 ? "toolUse" : "stop"; + stream.push({ type: "done", reason: stopReason, message: createAssistantMessage(content, stopReason) }); + callIndex++; + }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop( + [createUserMessage("go")], + { messages: [], tools: [hidden, runner] }, + { model: createModel(), convertToLlm: identityConverter }, + undefined, + streamFn, + ); + for await (const event of stream) events.push(event); + + expect(declaredTools[0]).toEqual(["runner"]); + expect(nestedText).toBe("secret"); + const directEnd = events.find( + (event): event is Extract => + event.type === "tool_execution_end" && event.toolCallId === "direct", + ); + expect(directEnd?.isError).toBe(true); + expect(directEnd?.result.content[0].text).toBe("Tool hidden not found"); + }); +}); diff --git a/packages/coding-agent/docs/configuration.md b/packages/coding-agent/docs/configuration.md index 40fdb0ef7..b16fd001d 100644 --- a/packages/coding-agent/docs/configuration.md +++ b/packages/coding-agent/docs/configuration.md @@ -12,6 +12,7 @@ The agent directory is shown as `` below. Set its location with the ` |---|---| | `/settings.json` | User-level [settings](settings.md), including preferences, defaults, resource paths, and Pi package declarations. | | `/keybindings.json` | Custom terminal UI and application [keybindings](keybindings.md). | +| `/mcp.json` | [MCP servers](mcp.md) available in every project. | | `/models.json` | [Compatible endpoints, models, and model overrides](models.md#configure-a-compatible-endpoint). | | `/auth.json` | Saved API keys and OAuth credentials. | | `/AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, or `CLAUDE.MD` | User instructions applied across working directories. | @@ -27,6 +28,7 @@ The agent directory is shown as `` below. Set its location with the ` | Path | Responsibility | |---|---| | `.pi/settings.json` | Project-level [settings](settings.md), resource paths, and Pi package declarations. | +| `.pi/mcp.json` | Project [MCP servers](mcp.md). | | `.pi/SYSTEM.md` | Replaces the system prompt for the project. | | `.pi/APPEND_SYSTEM.md` | Adds project-specific instructions to the system prompt. | | `.pi/extensions/` | Project extensions. | diff --git a/packages/coding-agent/docs/docs.json b/packages/coding-agent/docs/docs.json index f51b551c8..75ad0590e 100644 --- a/packages/coding-agent/docs/docs.json +++ b/packages/coding-agent/docs/docs.json @@ -91,6 +91,10 @@ { "title": "Use Pi Packages", "path": "packages.md" + }, + { + "title": "Connect MCP Servers", + "path": "mcp.md" } ] }, diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md new file mode 100644 index 000000000..c20452523 --- /dev/null +++ b/packages/coding-agent/docs/mcp.md @@ -0,0 +1,46 @@ +# MCP Servers + +Pi connects to [Model Context Protocol](https://modelcontextprotocol.io) servers over stdio or streamable HTTP and makes their tools available to the model. + +## Configure servers + +Add servers to `~/.pi/agent/mcp.json`, or to `.pi/mcp.json` in a project. The format matches other MCP clients, so existing `mcpServers` entries can be copied over: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] + }, + "docs": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }, + "exposure": "direct" + } + } +} +``` + +- stdio servers take `command`, `args`, `env`, and `cwd`. Relative `cwd` resolves against the session directory. +- HTTP servers take `url` and `headers`. The legacy SSE transport is not supported. +- `env` and `headers` values can reference environment variables (`${NAME}`) or commands (`!command`), like provider API keys. +- `timeout` sets the per-request timeout in seconds (default 60). Progress notifications from the server reset it. +- `enabled: false` keeps an entry without connecting to it. + +Project entries replace global entries with the same name. A project `mcp.json` is only read after the project is trusted, because stdio servers run commands. + +Pi connects when a session starts. The first prompt waits until startup connections finish. A server that drops its connection is reconnected on the next call. Run `/mcp` to see server status, tool counts, and errors. + +## Exposure + +Each server's tools are registered as `mcp____`. The `exposure` setting controls how the model reaches them: + +- `codemode` (default): the tools are callable from [codemode](cli.md#tools) scripts but are not declared to the model. Large MCP tool lists stay out of the model's tool declarations, and scripts can call several MCP tools, in parallel if needed, while returning only the part of the result the model needs. Pi activates the codemode tool when such a server connects. +- `direct`: the tools are declared to the model like built-in tools, and are also callable from codemode. + +MCP tools that declare an `outputSchema` return their `structuredContent` to codemode scripts. Other tools return their text output. Images are passed through as image content. + +Every MCP call goes through pi's tool pipeline, so `tool_call` and `tool_result` extension handlers, including permission gates, apply to MCP tools. Calls made from codemode scripts carry the codemode call's id as `parentToolCallId`. + +OAuth sign-in is not supported yet. Use header-based authentication for remote servers. diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 6178dd6c9..4f0dc290f 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -37,6 +37,7 @@ Project trust does not limit what tool calls can access or affect. After Pi star Pi requires a project-trust decision when it finds any of these resources from the current working directory: - `.pi/settings.json` +- `.pi/mcp.json` - `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes` - `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md` - project `.agents/skills` in the current directory or an ancestor directory @@ -46,6 +47,7 @@ A bare `.pi` directory does not require project trust. Granting project trust allows Pi to load: - project settings +- project MCP servers from `.pi/mcp.json` - extensions, skills, prompt templates, themes, and system-prompt files under `.pi` - missing packages configured through project settings - project-local and project-package extensions diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 36fbb9e2e..47ddd5a7e 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -476,6 +476,7 @@ "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-codemode": "^0.87.1", + "@earendil-works/pi-mcp": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", @@ -500,6 +501,17 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-mcp": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-0.87.1.tgz", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-telemetry": { "version": "0.87.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.1.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 50246d2fe..5cbe6dff1 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -13,6 +13,7 @@ "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-codemode": "^0.87.1", + "@earendil-works/pi-mcp": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", @@ -490,6 +491,17 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-mcp": { + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-0.87.1.tgz", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-telemetry": { "version": "0.87.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.1.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9beedd2be..1d90405c0 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -53,6 +53,7 @@ "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-codemode": "^0.87.1", + "@earendil-works/pi-mcp": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 40cf596ec..13725b53a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1395,10 +1395,12 @@ export class AgentSession { private _rebuildSystemPrompt(toolNames: string[]): void { const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); + // Nested-only tools are not declared to the model, so the prompt does not describe them. + const isNestedOnly = (name: string) => this._toolRegistry.get(name)?.nestedOnly === true; const toolSnippets: Record = {}; for (const name of this._toolRegistry.keys()) { const snippet = this._toolPromptSnippets.get(name); - if (snippet) toolSnippets[name] = snippet; + if (snippet && !isNestedOnly(name)) toolSnippets[name] = snippet; } const loaderSystemPrompt = this._resourceLoader.getSystemPrompt(); @@ -1415,7 +1417,7 @@ export class AgentSession { appendSystemPrompt, selectedTools: validToolNames, toolSnippets, - toolGuidelines: Object.fromEntries(this._toolPromptGuidelines), + toolGuidelines: Object.fromEntries([...this._toolPromptGuidelines].filter(([name]) => !isNestedOnly(name))), }); } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 71ab02344..8d8e16f74 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -497,6 +497,12 @@ export interface ToolDefinition( description: definition.description, parameters: definition.parameters, outputSchema: definition.outputSchema, + nestedOnly: definition.nestedOnly, constrainedSampling: definition.constrainedSampling, prepareArguments: definition.prepareArguments, executionMode: definition.executionMode, @@ -92,6 +93,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool): ToolDef description: tool.description, parameters: tool.parameters as any, outputSchema: tool.outputSchema, + nestedOnly: tool.nestedOnly, constrainedSampling: tool.constrainedSampling, prepareArguments: tool.prepareArguments, executionMode: tool.executionMode, diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 0a560f7f9..3396c9a39 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -29,6 +29,7 @@ type TrustFile = Record; const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ "settings.json", + "mcp.json", "extensions", "skills", "prompts", diff --git a/packages/coding-agent/src/extensions/index.ts b/packages/coding-agent/src/extensions/index.ts index a734df7d6..f39b05299 100644 --- a/packages/coding-agent/src/extensions/index.ts +++ b/packages/coding-agent/src/extensions/index.ts @@ -1,4 +1,8 @@ import type { InlineExtension } from "../core/extensions/types.ts"; import llamaExtension from "./llama/index.ts"; +import mcpExtension from "./mcp/index.ts"; -export const builtInExtensions: InlineExtension[] = [{ name: "llama.cpp", factory: llamaExtension, hidden: true }]; +export const builtInExtensions: InlineExtension[] = [ + { name: "llama.cpp", factory: llamaExtension, hidden: true }, + { name: "mcp", factory: mcpExtension, hidden: true }, +]; diff --git a/packages/coding-agent/src/extensions/mcp/config.ts b/packages/coding-agent/src/extensions/mcp/config.ts new file mode 100644 index 000000000..f9ec79fb9 --- /dev/null +++ b/packages/coding-agent/src/extensions/mcp/config.ts @@ -0,0 +1,143 @@ +/** + * MCP server configuration. + * + * Servers are read from `mcp.json` in the agent directory and, for trusted projects, from + * `/.pi/mcp.json`. Both use the `mcpServers` shape shared by other MCP clients, so + * existing configurations can be copied over. Project entries replace global entries with the + * same name. + * + * ```json + * { + * "mcpServers": { + * "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + * "docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" } } + * } + * } + * ``` + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { CONFIG_DIR_NAME } from "../../config.ts"; + +/** + * - `codemode`: tools are only callable from codemode scripts and are not declared to the model. + * - `direct`: tools are declared to the model like any other tool (and callable from codemode). + */ +export type McpExposure = "codemode" | "direct"; + +interface McpServerConfigBase { + /** Default: `codemode`. */ + exposure?: McpExposure; + /** Set to false to keep the entry without connecting. Default: true. */ + enabled?: boolean; + /** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */ + timeout?: number; +} + +export interface McpStdioServerConfig extends McpServerConfigBase { + type?: "stdio"; + command: string; + args?: string[]; + /** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */ + env?: Record; + /** Relative paths resolve against the session working directory. */ + cwd?: string; +} + +export interface McpHttpServerConfig extends McpServerConfigBase { + type?: "http"; + url: string; + /** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */ + headers?: Record; +} + +export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig; + +export interface McpServerEntry { + name: string; + config: McpServerConfig; + /** Config file that defined the entry. */ + source: string; +} + +export interface LoadedMcpConfig { + servers: McpServerEntry[]; + errors: string[]; +} + +const SERVER_NAME = /^[A-Za-z0-9_-]+$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} + +function validateServer(name: string, value: 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 } = value; + if (exposure !== undefined && exposure !== "codemode" && exposure !== "direct") { + return `server "${name}": exposure must be "codemode" or "direct"`; + } + if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`; + if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) { + return `server "${name}": timeout must be a positive number of seconds`; + } + if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`; + + if (typeof value.url === "string" && (type === undefined || type === "http" || type === "streamable-http")) { + if (value.headers !== undefined && !isStringRecord(value.headers)) { + return `server "${name}": headers must map names to strings`; + } + return value as unknown as McpHttpServerConfig; + } + if (typeof value.command === "string" && (type === undefined || type === "stdio")) { + if ( + value.args !== undefined && + !(Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string")) + ) { + return `server "${name}": args must be an array of strings`; + } + if (value.env !== undefined && !isStringRecord(value.env)) + return `server "${name}": env must map names to strings`; + if (value.cwd !== undefined && typeof value.cwd !== "string") return `server "${name}": cwd must be a string`; + return value as unknown as McpStdioServerConfig; + } + return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`; +} + +function readConfigFile(path: string, servers: Map, errors: string[]): void { + if (!existsSync(path)) return; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) { + errors.push(`${path}: expected an object with an "mcpServers" object`); + return; + } + for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { + const config = validateServer(name, value); + if (typeof config === "string") { + errors.push(`${path}: ${config}`); + continue; + } + servers.set(name, { name, config, source: path }); + } +} + +/** Load global and (when trusted) project MCP configuration. Disabled servers are omitted. */ +export function loadMcpConfig(options: { agentDir: string; cwd: string; projectTrusted: boolean }): LoadedMcpConfig { + const servers = new Map(); + const errors: string[] = []; + readConfigFile(join(options.agentDir, "mcp.json"), servers, errors); + if (options.projectTrusted) readConfigFile(join(options.cwd, CONFIG_DIR_NAME, "mcp.json"), servers, errors); + return { servers: [...servers.values()].filter((entry) => entry.config.enabled !== false), errors }; +} diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts new file mode 100644 index 000000000..d02b7ca40 --- /dev/null +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -0,0 +1,252 @@ +/** + * Built-in MCP integration. + * + * Connects the servers from `mcp.json` when a session starts and registers their tools 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. `"exposure": "direct"` declares them to the model as well. + * + * Every call runs through pi's tool pipeline, so `tool_call`/`tool_result` hooks and permission + * extensions apply to MCP tools the same way they do to built-in tools. + */ + +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + McpClient, + type Tool as McpTool, + type McpTransport, + StdioTransport, + StreamableHttpTransport, +} from "@earendil-works/pi-mcp"; +import { getAgentDir, VERSION } from "../../config.ts"; +import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "../../core/extensions/types.ts"; +import { resolveConfigValueOrThrow, resolveHeadersOrThrow } from "../../core/resolve-config-value.ts"; +import { CODEMODE_TOOL_NAME } from "../../core/tools/codemode.ts"; +import { type LoadedMcpConfig, loadMcpConfig, type McpServerEntry } from "./config.ts"; +import { createMcpToolDefinition, createMcpToolName } from "./tools.ts"; + +const DEFAULT_TIMEOUT_SECONDS = 60; +const STDERR_TAIL_CHARS = 2_000; + +type ServerState = "connecting" | "connected" | "failed" | "closed"; + +export interface McpExtensionOptions { + /** Defaults to reading `mcp.json` from the agent directory and the trusted project. */ + loadConfig?: (ctx: ExtensionContext) => LoadedMcpConfig; + /** Defaults to stdio and streamable HTTP transports built from the server config. */ + createTransport?: (entry: McpServerEntry, cwd: string) => McpTransport; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createDefaultTransport(entry: McpServerEntry, cwd: string): McpTransport { + const { config, name } = entry; + if ("url" in config) { + return new StreamableHttpTransport({ + url: config.url, + headers: resolveHeadersOrThrow(config.headers, `MCP server "${name}"`), + }); + } + const env: Record = {}; + for (const [key, value] of Object.entries(config.env ?? {})) { + env[key] = resolveConfigValueOrThrow(value, `MCP server "${name}" env "${key}"`); + } + return new StdioTransport({ + command: config.command, + args: config.args, + cwd: resolve(cwd, config.cwd ?? "."), + env, + stderr: "pipe", + }); +} + +/** One configured server. Reconnects lazily when a call finds the connection gone. */ +class McpServerConnection { + readonly entry: McpServerEntry; + state: ServerState = "connecting"; + error: string | undefined; + tools: McpTool[] = []; + private client: McpClient | undefined; + private opening: Promise | undefined; + private closed = false; + private readonly cwd: string; + private readonly createTransport: (entry: McpServerEntry, cwd: string) => McpTransport; + private readonly onTools: (connection: McpServerConnection) => void; + + constructor( + entry: McpServerEntry, + cwd: string, + createTransport: (entry: McpServerEntry, cwd: string) => McpTransport, + onTools: (connection: McpServerConnection) => void, + ) { + this.entry = entry; + this.cwd = cwd; + this.createTransport = createTransport; + this.onTools = onTools; + } + + get timeoutMs(): number { + return (this.entry.config.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000; + } + + getClient(): Promise { + if (this.closed) return Promise.reject(new Error(`MCP server "${this.entry.name}" is shut down`)); + if (this.client?.connectionState === "connected") return Promise.resolve(this.client); + this.opening ??= this.open().finally(() => { + this.opening = undefined; + }); + return this.opening; + } + + private async open(): Promise { + this.state = "connecting"; + const client = new McpClient({ + name: "pi", + version: VERSION, + requestTimeoutMs: this.timeoutMs, + roots: [{ uri: pathToFileURL(this.cwd).href, name: basename(this.cwd) }], + }); + let transport: McpTransport | undefined; + try { + transport = this.createTransport(this.entry, this.cwd); + await client.connect(transport); + client.onNotification("notifications/tools/list_changed", () => { + void this.refreshTools(client); + }); + const tools = await client.listTools(); + if (this.closed) throw new Error("shut down while connecting"); + this.client = client; + this.tools = tools; + this.state = "connected"; + this.error = undefined; + this.onTools(this); + return client; + } catch (error) { + await client.close().catch(() => undefined); + const stderr = transport instanceof StdioTransport ? transport.stderr.trim().slice(-STDERR_TAIL_CHARS) : ""; + this.state = this.closed ? "closed" : "failed"; + this.error = stderr ? `${errorMessage(error)}\n${stderr}` : errorMessage(error); + throw new Error(`MCP server "${this.entry.name}" failed to connect: ${this.error}`); + } + } + + private async refreshTools(client: McpClient): Promise { + try { + const tools = await client.listTools(); + if (this.client !== client || this.closed) return; + this.tools = tools; + this.onTools(this); + } catch (error) { + this.error = `Failed to refresh tools: ${errorMessage(error)}`; + } + } + + async close(): Promise { + this.closed = true; + this.state = "closed"; + const client = this.client; + this.client = undefined; + await client?.close().catch(() => undefined); + } +} + +function formatStatus(connections: readonly McpServerConnection[], configErrors: readonly string[]): string { + if (connections.length === 0 && configErrors.length === 0) { + return `No MCP servers configured. Add them to ${resolve(getAgentDir(), "mcp.json")} or .pi/mcp.json.`; + } + const lines = connections.map((connection) => { + const exposure = connection.entry.config.exposure ?? "codemode"; + const tools = connection.state === "connected" ? `, ${connection.tools.length} tools` : ""; + const error = connection.error ? `\n ${connection.error.split("\n").join("\n ")}` : ""; + return `${connection.entry.name}: ${connection.state}${tools} (${exposure})${error}`; + }); + for (const error of configErrors) lines.push(`config error: ${error}`); + return lines.join("\n"); +} + +export function createMcpExtension(options: McpExtensionOptions = {}): ExtensionFactory { + return (pi: ExtensionAPI) => { + let connections: McpServerConnection[] = []; + let configErrors: string[] = []; + let pending: Promise | undefined; + + const registerTools = (connection: McpServerConnection) => { + const { name: server, config } = connection.entry; + const exposure = config.exposure ?? "codemode"; + for (const tool of connection.tools) { + const name = createMcpToolName(server, tool.name); + pi.registerTool( + createMcpToolDefinition({ + server, + tool, + name, + exposure, + timeoutMs: connection.timeoutMs, + getClient: () => connection.getClient(), + }), + ); + } + }; + + /** Codemode-exposed tools are unreachable without the codemode tool, so turn it on. */ + const ensureCodemodeActive = (ctx: ExtensionContext) => { + const needsCodemode = connections.some( + (connection) => + connection.state === "connected" && (connection.entry.config.exposure ?? "codemode") === "codemode", + ); + if (!needsCodemode || pi.getActiveTools().includes(CODEMODE_TOOL_NAME)) return; + if (!pi.getAllTools().some((tool) => tool.name === CODEMODE_TOOL_NAME)) { + ctx.ui.notify( + 'MCP tools use "exposure": "codemode" but the codemode tool is not available; they cannot be called.', + "warning", + ); + return; + } + pi.setActiveTools([...pi.getActiveTools(), CODEMODE_TOOL_NAME]); + }; + + pi.on("session_start", (_event, ctx) => { + const loaded = (options.loadConfig ?? defaultLoadConfig)(ctx); + configErrors = loaded.errors; + for (const error of configErrors) ctx.ui.notify(`MCP config: ${error}`, "warning"); + const createTransport = options.createTransport ?? createDefaultTransport; + connections = loaded.servers.map( + (entry) => new McpServerConnection(entry, ctx.cwd, createTransport, registerTools), + ); + if (connections.length === 0) return; + pending = Promise.allSettled( + connections.map((connection) => + connection.getClient().catch((error: unknown) => ctx.ui.notify(errorMessage(error), "warning")), + ), + ).then(() => ensureCodemodeActive(ctx)); + }); + + // The first prompt waits for startup connections so their tools are available to it. + pi.on("before_agent_start", async () => { + await pending; + }); + + pi.on("session_shutdown", async () => { + const closing = connections; + connections = []; + await Promise.all(closing.map((connection) => connection.close())); + }); + + pi.registerCommand("mcp", { + description: "Show MCP server status", + handler: async (_args, ctx) => { + await pending; + ctx.ui.notify(formatStatus(connections, configErrors), "info"); + }, + }); + }; +} + +function defaultLoadConfig(ctx: ExtensionContext): LoadedMcpConfig { + return loadMcpConfig({ agentDir: getAgentDir(), cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }); +} + +export default createMcpExtension(); diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts new file mode 100644 index 000000000..e94e83106 --- /dev/null +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -0,0 +1,127 @@ +/** + * Adapts MCP tools to pi tool definitions. + * + * Results map onto pi's model-facing content (text and images). `structuredContent` is passed + * through for tools that declare an `outputSchema`, so codemode scripts receive the data instead + * of the text. MCP errors (`isError`) become thrown errors, which pi reports as failed calls. + */ + +import { createHash } from "node:crypto"; +import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { ImageContent, JsonValue, TextContent } from "@earendil-works/pi-ai"; +import type { CallToolResult, ContentBlock, McpRequestOptions, Tool as McpTool } from "@earendil-works/pi-mcp"; +import type { TSchema } from "typebox"; +import type { ToolDefinition } from "../../core/extensions/types.ts"; +import type { McpExposure } from "./config.ts"; + +/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */ +const MAX_TOOL_NAME_LENGTH = 64; + +export interface McpToolDetails { + server: string; + tool: string; +} + +export interface McpToolCaller { + callTool(name: string, args: Record, options: McpRequestOptions): Promise; +} + +/** `mcp____`, sanitized and shortened with a hash suffix when too long. */ +export function createMcpToolName(server: string, tool: string): string { + const name = `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_-]/g, "_"); + if (name.length <= MAX_TOOL_NAME_LENGTH) return name; + const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8); + return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`; +} + +function blockToContent(block: ContentBlock): TextContent | ImageContent { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + case "image": + return { type: "image", data: block.data, mimeType: block.mimeType }; + case "audio": + return { type: "text", text: `[audio ${block.mimeType} omitted]` }; + case "resource_link": + return { type: "text", text: `${block.name}: ${block.uri}` }; + case "resource": { + const resource = block.resource; + if ("text" in resource) return { type: "text", text: resource.text }; + if (resource.mimeType?.startsWith("image/")) { + return { type: "image", data: resource.blob, mimeType: resource.mimeType }; + } + return { + type: "text", + text: `[binary resource ${resource.uri} (${resource.mimeType ?? "unknown type"}) omitted]`, + }; + } + default: + return { type: "text", text: `[unsupported MCP content ${(block as { type: string }).type}]` }; + } +} + +function textOf(content: readonly (TextContent | ImageContent)[]): string { + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +/** Convert an MCP result, throwing for `isError` results. */ +export function convertMcpResult( + server: string, + tool: string, + result: CallToolResult, +): AgentToolResult { + const content = result.content.map(blockToContent); + if (result.isError) { + throw new Error(textOf(content) || `MCP tool ${server}/${tool} returned an error`); + } + // Servers should mirror structured results as text; fall back to JSON when they do not. + if (content.length === 0 && result.structuredContent !== undefined) { + content.push({ type: "text", text: JSON.stringify(result.structuredContent, null, 2) }); + } + return { + content, + details: { server, tool }, + ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent as JsonValue }), + }; +} + +/** Tool input schemas must be objects; MCP allows servers to omit `type`. */ +function toParameters(schema: Record): TSchema { + return (schema.type === undefined ? { type: "object", ...schema } : schema) as unknown as TSchema; +} + +export function createMcpToolDefinition(options: { + server: string; + tool: McpTool; + name: string; + exposure: McpExposure; + timeoutMs: number; + getClient: () => Promise; +}): ToolDefinition { + const { server, tool } = options; + const title = tool.title ?? tool.annotations?.title; + return { + name: options.name, + label: `${server}/${tool.name}`, + description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, + parameters: toParameters(tool.inputSchema), + ...(tool.outputSchema ? { outputSchema: tool.outputSchema as unknown as TSchema } : {}), + ...(options.exposure === "codemode" ? { nestedOnly: true } : {}), + async execute(_toolCallId, params, signal, onUpdate) { + const client = await options.getClient(); + const result = await client.callTool(tool.name, (params ?? {}) as Record, { + signal, + timeoutMs: options.timeoutMs, + onProgress: (progress) => { + const total = progress.total === undefined ? "" : `/${progress.total}`; + const text = progress.message ?? `Progress ${progress.progress}${total}`; + onUpdate?.({ content: [{ type: "text", text }], details: { server, tool: tool.name } }); + }, + }); + return convertMcpResult(server, tool.name, result); + }, + }; +} diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts new file mode 100644 index 000000000..bb48ca23c --- /dev/null +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadMcpConfig } from "../src/extensions/mcp/config.ts"; +import { convertMcpResult, createMcpToolName } from "../src/extensions/mcp/tools.ts"; + +// Config values are resolved at connect time, so the literal reference must survive loading. +// biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference +const TOKEN_HEADER = "Bearer ${TOKEN}"; + +describe("MCP config", () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function setup(global: unknown, project: unknown) { + const root = mkdtempSync(join(tmpdir(), "pi-mcp-config-")); + dirs.push(root); + const agentDir = join(root, "agent"); + const cwd = join(root, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "mcp.json"), JSON.stringify(global)); + writeFileSync(join(cwd, ".pi", "mcp.json"), JSON.stringify(project)); + return { agentDir, cwd }; + } + + it("merges global and trusted project servers and validates entries", () => { + const paths = setup( + { + mcpServers: { + shared: { command: "global-cmd" }, + remote: { url: "https://example.com/mcp", headers: { Authorization: TOKEN_HEADER } }, + off: { command: "x", enabled: false }, + bad: { args: ["no command"] }, + legacy: { type: "sse", url: "https://example.com/sse" }, + }, + }, + { mcpServers: { shared: { command: "project-cmd", exposure: "direct" } } }, + ); + + const trusted = loadMcpConfig({ ...paths, projectTrusted: true }); + expect(trusted.servers.map((server) => [server.name, server.config])).toEqual([ + ["shared", { command: "project-cmd", exposure: "direct" }], + ["remote", { url: "https://example.com/mcp", headers: { Authorization: TOKEN_HEADER } }], + ]); + expect(trusted.errors).toHaveLength(2); + expect(trusted.errors[0]).toContain('server "bad" needs either "command"'); + expect(trusted.errors[1]).toContain("legacy SSE transport is not supported"); + + // Untrusted projects cannot add or override servers, since stdio servers run commands. + const untrusted = loadMcpConfig({ ...paths, projectTrusted: false }); + expect(untrusted.servers.find((server) => server.name === "shared")?.config).toEqual({ command: "global-cmd" }); + }); +}); + +describe("MCP tools", () => { + it("creates provider-safe tool names", () => { + expect(createMcpToolName("docs", "search")).toBe("mcp__docs__search"); + expect(createMcpToolName("my-server", "get.item/v2")).toBe("mcp__my-server__get_item_v2"); + const long = createMcpToolName("server", "x".repeat(100)); + expect(long).toHaveLength(64); + expect(long).toMatch(/^mcp__server__x+_[0-9a-f]{8}$/); + expect(createMcpToolName("server", `${"x".repeat(100)}y`)).not.toBe(long); + }); + + it("converts results, keeping structured content and throwing for errors", () => { + expect( + convertMcpResult("docs", "t", { + content: [ + { type: "resource_link", uri: "file:///a", name: "a" }, + { type: "resource", resource: { uri: "file:///b", text: "b text" } }, + { type: "audio", data: "", mimeType: "audio/wav" }, + ], + structuredContent: { ok: true }, + }), + ).toEqual({ + content: [ + { type: "text", text: "a: file:///a" }, + { type: "text", text: "b text" }, + { type: "text", text: "[audio audio/wav omitted]" }, + ], + details: { server: "docs", tool: "t" }, + structuredContent: { ok: true }, + }); + expect(convertMcpResult("docs", "t", { content: [], structuredContent: { n: 1 } }).content).toEqual([ + { type: "text", text: '{\n "n": 1\n}' }, + ]); + expect(() => convertMcpResult("docs", "t", { content: [{ type: "text", text: "nope" }], isError: true })).toThrow( + "nope", + ); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-mcp.test.ts b/packages/coding-agent/test/suite/agent-session-mcp.test.ts new file mode 100644 index 000000000..4468ef80a --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-mcp.test.ts @@ -0,0 +1,198 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import type { SystemMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat"; +import { type JsonRpcRequest, LATEST_PROTOCOL_VERSION } from "@earendil-works/pi-mcp"; +import { createInMemoryTransportPair } from "@earendil-works/pi-mcp/testing"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCodemodeTool } from "../../src/core/tools/codemode.ts"; +import type { McpExposure, McpServerEntry } from "../../src/extensions/mcp/config.ts"; +import { createMcpExtension } from "../../src/extensions/mcp/index.ts"; +import { createMcpToolName } from "../../src/extensions/mcp/tools.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + +const SERVER_TOOLS = [ + { + name: "search", + description: "Search the docs.", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + outputSchema: { + type: "object", + properties: { hits: { type: "array", items: { type: "string" } } }, + required: ["hits"], + }, + }, + { name: "fail", description: "Always fails.", inputSchema: { type: "object", properties: {} } }, + { name: "shot", description: "Returns an image.", inputSchema: { type: "object", properties: {} } }, +]; + +/** Minimal MCP server over an in-memory transport. Records the tool calls it receives. */ +function createFakeServer(calls: string[]) { + const pair = createInMemoryTransportPair(); + const respond = (request: JsonRpcRequest): unknown => { + switch (request.method) { + case "initialize": + return { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "docs", version: "1.0.0" }, + }; + case "tools/list": + return { tools: SERVER_TOOLS }; + case "tools/call": { + const params = request.params as { name: string; arguments?: { query?: string } }; + calls.push(`${params.name}:${JSON.stringify(params.arguments ?? {})}`); + if (params.name === "search") { + const hits = [`${params.arguments?.query} guide`, `${params.arguments?.query} faq`]; + return { content: [{ type: "text", text: hits.join("\n") }], structuredContent: { hits } }; + } + if (params.name === "shot") { + return { content: [{ type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }] }; + } + return { content: [{ type: "text", text: "server exploded" }], isError: true }; + } + default: + return {}; + } + }; + pair.server.onMessage((message) => { + if (!("id" in message) || !("method" in message)) return; + const request = message as JsonRpcRequest; + queueMicrotask(() => { + void pair.server.send({ jsonrpc: "2.0", id: request.id, result: respond(request) }); + }); + }); + return pair; +} + +describe("AgentSession MCP integration", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + async function setup(exposure: McpExposure) { + const calls: string[] = []; + const entry: McpServerEntry = { + name: "docs", + config: { url: "http://unused.invalid", exposure }, + source: "test", + }; + const harness = await createHarness({ + tools: [createCodemodeTool()], + initialActiveToolNames: [], + extensionFactories: [ + createMcpExtension({ + loadConfig: () => ({ servers: [entry], errors: [] }), + createTransport: () => { + const pair = createFakeServer(calls); + void pair.server.start(); + return pair.client; + }, + }), + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + return { harness, calls }; + } + + function declaredToolNames(harness: Harness): string[] { + return harness.session.messages + .filter((message): message is SystemMessage => message.role === "system") + .flatMap((message) => (message.toolsAdded ?? []).map((tool) => tool.name)); + } + + function toolResult(harness: Harness, toolName: string): ToolResultMessage { + const result = harness.session.messages.find( + (message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === toolName, + ); + if (!result) throw new Error(`No ${toolName} tool result`); + return result; + } + + function text(message: ToolResultMessage): string { + return message.content.map((block) => (block.type === "text" ? block.text : `<${block.type}>`)).join("\n"); + } + + it("exposes codemode-only MCP tools through codemode and hides them from the model", async () => { + const { harness, calls } = await setup("codemode"); + const searchName = createMcpToolName("docs", "search"); + harness.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall("codemode", { + code: ` + const [a, b] = await Promise.all([ + tools.${searchName}({ query: "mcp" }), + tools.${searchName}({ query: "pi" }), + ]); + let failure; + try { await tools.mcp__docs__fail({}); } catch (error) { failure = error.message; } + const shot = await tools.mcp__docs__shot({}); + image(shot); + return { hits: [...a.hits, ...b.hits], failure, shot }; + `, + }), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("search the docs"); + + // Codemode was activated for the codemode-exposed server; MCP tools are never declared. + expect(harness.session.getActiveToolNames()).toContain("codemode"); + expect(harness.session.getActiveToolNames()).toContain(searchName); + expect(declaredToolNames(harness)).toEqual(["codemode"]); + const codemode = harness.session.agent.state.tools.find((tool) => tool.name === "codemode"); + expect(codemode?.description).toContain(`${searchName}(args: {`); + expect(codemode?.description).toContain("}): Promise<{\n hits: string[];\n }>;"); + + const result = toolResult(harness, "codemode"); + expect(result.isError).toBe(false); + const [json] = text(result).split("\n"); + expect(JSON.parse(json)).toEqual({ + hits: ["mcp guide", "mcp faq", "pi guide", "pi faq"], + failure: "server exploded", + shot: "[image:1 image/png]", + }); + expect(result.content.at(-1)?.type).toBe("image"); + expect(calls).toEqual(['search:{"query":"mcp"}', 'search:{"query":"pi"}', "fail:{}", "shot:{}"]); + }); + + it("rejects direct model calls to codemode-only MCP tools", async () => { + const { harness, calls } = await setup("codemode"); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall(createMcpToolName("docs", "search"), { query: "x" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("go"); + + const result = toolResult(harness, "mcp__docs__search"); + expect(result.isError).toBe(true); + expect(text(result)).toBe("Tool mcp__docs__search not found"); + expect(calls).toEqual([]); + }); + + it("declares directly exposed MCP tools to the model", async () => { + const { harness } = await setup("direct"); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("mcp__docs__search", { query: "direct" })], { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("go"); + + expect(declaredToolNames(harness)).toEqual(["mcp__docs__search", "mcp__docs__fail", "mcp__docs__shot"]); + expect(harness.session.getActiveToolNames()).not.toContain("codemode"); + const result = toolResult(harness, "mcp__docs__search"); + expect(text(result)).toBe("direct guide\ndirect faq"); + }); +}); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 65dcf18cb..9bee886bb 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-mcp", - "version": "0.85.1", + "version": "0.87.1", "description": "Standalone Model Context Protocol client for pi and other applications", "type": "module", "main": "./dist/index.js",