diff --git a/apps/p2/docs/handoff.md b/apps/p2/docs/handoff.md index fd86b18ff..c9908ec9b 100644 --- a/apps/p2/docs/handoff.md +++ b/apps/p2/docs/handoff.md @@ -109,7 +109,7 @@ verifying an agent's files on their own before committing. Node 24: `~/node-v24. - **Durable** (`packages/durable`) runs the agent: conversations, inputs as custom entries (`p2.input`, `chat.mention`), display-only writes placed immediately, code mode (QuickJS sandbox; scripts call tools as `tools.`, one-shot agents via `tools.agent`), compaction with visible failures, deferred tools and - `tool_search` (MCP tools are deferred). + `tool_search` (MCP tools are for code mode scripts, as in pi; `tool_search` loads them only while code mode is off). ## The TUI @@ -155,8 +155,8 @@ From `AGENTS.md` and this project's history: 3. **Cleanup leftovers:** two HTTP helpers were not merged because their semantics differ (byte ranges: files serves `bytes=-` whole, workspace answers 416; body readers: sharing answers 400 past the limit, devices 413); decide one behavior each. -4. **MCP:** `plugins/mcp` (WP6 and WP7 of `docs/mcp-durable.md`: owner `mcp.json`, host stdio and HTTP, deferred - tools, `tool_search`, servers on environments, project `.pi/mcp.json`); OAuth (WP8) and code mode namespaces (WP9) +4. **MCP:** `plugins/mcp` (WP6 and WP7 of `docs/mcp-durable.md`: owner `mcp.json`, host stdio and HTTP, code mode + tools (`tool_search` without code mode), servers on environments, project `.pi/mcp.json`); OAuth (WP8) and code mode namespaces (WP9) are open. 5. **npm:** deprecate `@earendil-works/pi-client`, `pi-protocol` and `pi-server` (1.1.0), which no longer exist. 6. `docs/p2-notes.md` TODO: the TypeScript 5.9.3 pin (Termux), an isolating plugin loader, sandboxing third-party diff --git a/apps/p2/docs/mcp-durable.md b/apps/p2/docs/mcp-durable.md index 5777d4766..adbf23574 100644 --- a/apps/p2/docs/mcp-durable.md +++ b/apps/p2/docs/mcp-durable.md @@ -503,12 +503,18 @@ export const Mcp = remote("p2.mcp", { ...)` rebuilds it. Tools: - name via `createMcpToolName` (WP4, else copied), description from the MCP tool, `parameters` normalized to an object schema; - - `deferred: true` (found through `tool_search`); + - exposed as pi's default (`"exposure": "codemode"`): `deferred: true` and `callers: ["tools"]`, so only code mode + scripts call them (`searchTools()` finds them; neither the `codemode` section nor `tool_search` lists them). While + code mode is off or its plugin is not loaded (its `ExtensionSwitches` entry is not `true`), pi's fallback: + `deferred: true` alone, loaded through `tool_search`; + - the `mcp_servers` section, as pi's: each server's namespace and how its tools are reached (no summaries yet: p2 + has no `description` field and does not pass on the servers' `instructions`); - `execute`: locate the calling conversation (`api.conversationId`, through the harness's environments), pick the instance for that location, call `view.call` from Durable code (design §24's helper), convert the result with `toLlmContent()`, pass `structuredContent` through as `structuredOutput` only if the tool declares an output schema, `isError` from the MCP result; replay stays `unsafe`. -- Register `createToolSearchExtension()` (nothing installs `tool_search` in p2 today). +- Install `createToolSearchExtension()` only while it reaches the MCP tools (code mode off, servers present), as pi + activates `tool_search` only for `deferred` servers. Per-server `exposure` and `toolExposure` are not read yet. ### 9.5 Owner configuration (`/mcp.json`) diff --git a/apps/p2/plugins/mcp/tools.ts b/apps/p2/plugins/mcp/tools.ts index cd1bd2144..762cfcbb3 100644 --- a/apps/p2/plugins/mcp/tools.ts +++ b/apps/p2/plugins/mcp/tools.ts @@ -1,5 +1,7 @@ -// MCP tools as Durable tools: one deferred tool (found through `tool_search`) per tool of the session's servers, named -// as pi names them (`mcp____`), calling the server's instance where the calling conversation works. +// MCP tools as Durable tools, exposed as pi exposes them by default: one tool per tool of the session's servers, named as +// pi names them (`mcp____`), callable only from code mode scripts (or loaded through `tool_search` while +// code mode is off), calling the server's instance where the calling conversation works; and the `mcp_servers` prompt +// section, through which the model learns of the servers. import type { JsonValue } from "@earendil-works/chord/json"; import { @@ -7,12 +9,14 @@ import { type DocumentReader, defineExtension, type Extension, + section, type ToolRegistration, } from "@earendil-works/pi-durable"; import { type CallToolResult, createMcpToolName, limitMcpContent, + mcpNamespace, normalizeMcpInputSchema, toLlmContent, } from "@earendil-works/pi-mcp"; @@ -22,6 +26,20 @@ import { type Instance, MCP_EXTENSION, type McpTool } from "./contract.ts"; /** One tool of one server. */ export type ServerTool = { readonly server: string; readonly tool: McpTool }; +/** + * How the model reaches MCP tools, as pi's exposures: `codemode` (pi's default) from code mode scripts, which find them + * with `searchTools()`; `tool_search` (pi's `deferred`, its fallback without code mode) by loading them with + * `tool_search`, then calling them directly. + */ +export type McpReach = "codemode" | "tool_search"; + +/** What the MCP extension offers: the session's servers by name, their tools, and how the model reaches them. */ +export type McpOffer = { + readonly servers: readonly string[]; + readonly tools: readonly ServerTool[]; + readonly reach: McpReach; +}; + /** A call of `server`'s `tool` for conversation `conversationId` (its documents read through `read`). */ export type McpCall = ( request: { @@ -46,18 +64,24 @@ export function serverTools(servers: readonly Instance[]): ServerTool[] { return [...tools.values()].sort((a, b) => order(a.server, b.server) || order(a.tool.name, b.tool.name)); } +/** The servers of `instances` by name, each once (a server may have instances at several locations). */ +export function serverNames(instances: readonly Instance[]): string[] { + return [...new Set(instances.map((instance) => instance.server))].sort(); +} + /** - * The Durable extension offering `tools`, each calling through `call`. As in pi (and Codex), every tool whose name - * sanitizes to one another tool's also gets pi's hash suffix, so no tool's name depends on the order of the list - * and a new tool renames only those it collides with. + * The Durable extension offering `offer`'s tools, each calling through `call`, and the `mcp_servers` section. As in pi + * (and Codex), every tool whose name sanitizes to one another tool's also gets pi's hash suffix, so no tool's name + * depends on the order of the list and a new tool renames only those it collides with. */ -export function createMcpExtension(tools: readonly ServerTool[], call: McpCall): Extension { +export function createMcpExtension({ servers, tools, reach }: McpOffer, call: McpCall): Extension { const plain = new Map(); for (const { server, tool } of tools) { const name = createMcpToolName(server, tool.name); plain.set(name, (plain.get(name) ?? 0) + 1); } const assigned = new Set(); + const listing = serversSection(servers, reach); return defineExtension({ name: MCP_EXTENSION, tools: tools.map((entry) => { @@ -67,19 +91,59 @@ export function createMcpExtension(tools: readonly ServerTool[], call: McpCall): (candidate) => (plain.get(candidate) ?? 0) > 1 || assigned.has(candidate), ); assigned.add(name); - return toolRegistration(name, entry, call); + return toolRegistration(name, entry, reach, call); }), + sections: listing === undefined ? [] : [section(MCP_SERVERS_SECTION, () => listing)], }); } -/** One MCP tool as a deferred Durable tool (not replay-safe: an interrupted call is not repeated). */ -function toolRegistration(name: string, { server, tool }: ServerTool, call: McpCall): ToolRegistration { +/** The section listing the servers whose tools are not declared to the model, as pi's. */ +export const MCP_SERVERS_SECTION = "mcp_servers"; + +/** + * Characters of the whole section, as in pi: when the server lines do not fit, the last servers are left out and + * counted in a closing line. + */ +const MAX_SERVERS_SECTION_CHARS = 4096; + +/** + * The `mcp_servers` section, as pi renders it for servers without a description: each server's namespace and how its + * tools are reached. Undefined without servers. + */ +function serversSection(servers: readonly string[], reach: McpReach): string | undefined { + if (servers.length === 0) return undefined; + const intro = + reach === "codemode" + ? "MCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts." + : "MCP servers whose tools are not declared to you. Load the tools of `tool_search` servers with `tool_search`."; + const lines = servers.map((server) => `- ${mcpNamespace(server)} (${reach})`); + const find = reach === "codemode" ? "searchTools()" : "tool_search"; + const omitted = (count: number) => + count > 0 ? [`- … ${count} more server${count === 1 ? "" : "s"}; find their tools with ${find}`] : []; + const render = (kept: number) => [intro, ...lines.slice(0, kept), ...omitted(lines.length - kept)].join("\n"); + let kept = lines.length; + while (kept > 0 && render(kept).length > MAX_SERVERS_SECTION_CHARS) kept--; + return render(kept); +} + +/** + * One MCP tool as a Durable tool (not replay-safe: an interrupted call is not repeated). Deferred either way, so the + * `codemode` section leaves it out; for `codemode` only other tools (scripts) may call it, so `tool_search` never + * offers it to the model either. + */ +function toolRegistration( + name: string, + { server, tool }: ServerTool, + reach: McpReach, + call: McpCall, +): ToolRegistration { const title = tool.title ?? tool.annotations?.title; return { name, description: tool.description?.trim() || title || `MCP tool ${tool.name} from server ${server}`, parameters: normalizeMcpInputSchema(tool.inputSchema), deferred: true, + ...(reach === "codemode" ? { callers: ["tools" as const] } : {}), ...(tool.outputSchema === undefined ? {} : { structuredOutputSchema: tool.outputSchema }), async execute(args, api, context) { // Durable checked `args` against `parameters`, an object schema; its type cannot follow a runtime schema. diff --git a/apps/p2/plugins/mcp/worker.ts b/apps/p2/plugins/mcp/worker.ts index 3c3623050..e966488a7 100644 --- a/apps/p2/plugins/mcp/worker.ts +++ b/apps/p2/plugins/mcp/worker.ts @@ -1,8 +1,9 @@ -// The MCP plugin's worker facet: `tool_search` (deferred tools, MCP's among them, are found through it) and the worker -// object holding the server's `Mcp` view. It sets the locations the session's conversations work at (the session's, -// active subagents' own), with the project `.pi/mcp.json` read at each through its environment, as its leases, again -// whenever they may have changed and whenever the view (re)activates, and offers the servers' tools as one Durable -// extension that follows the view's `servers`. No MCP process or credential is ever in a worker. +// The MCP plugin's worker facet: the worker object holding the server's `Mcp` view. It sets the locations the session's +// conversations work at (the session's, active subagents' own), with the project `.pi/mcp.json` read at each through +// its environment, as its leases, again whenever they may have changed and whenever the view (re)activates, and offers +// the servers' tools as one Durable extension that follows the view's `servers` and the code mode switch: as in pi, +// tools are for code mode scripts, and only without code mode does `tool_search` load them for the model. No MCP +// process or credential is ever in a worker. import { setTimeout as sleep } from "node:timers/promises"; import { type ConversationId, type Extension, type Harness, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable"; @@ -21,12 +22,14 @@ import { signalOf, until, untilAborted, + type Watchable, type Writable, } from "@p2/app/v1"; import { durableContext } from "@p2/app/v1/node"; import { CommitSignal, type ExecutionEnvironments, + ExtensionSwitches, Extensions, type HarnessParts, harnessOf, @@ -34,15 +37,16 @@ import { workerOf, } from "@p2/app/v1/worker"; import { Subagents } from "../subagents/contract.worker.ts"; -import { type Instance, MCP_EXTENSION, Mcp, PROJECT_FILE_MAX_CHARS, type ProjectFile } from "./contract.ts"; -import { createMcpExtension, type McpCall, serverTools } from "./tools.ts"; +import { MCP_EXTENSION, Mcp, PROJECT_FILE_MAX_CHARS, type ProjectFile } from "./contract.ts"; +import { createMcpExtension, type McpCall, type McpOffer, type McpReach, serverNames, serverTools } from "./tools.ts"; /** How long to wait before setting the locations again after a failure. */ const RETRY_MS = 2000; -export default defineFacet((host, facet) => { - const search = createToolSearchExtension(); - facet.contribute(Extensions, { [search.name]: search }); +/** The code mode plugin's extension: its switch is on while the `codemode` setting is, absent without the plugin. */ +const CODEMODE_EXTENSION = "codemode"; + +export default defineFacet((host) => { contribute(host, "p2.mcp-worker", mcpWorker); }); @@ -58,36 +62,67 @@ function mcpWorker(self: StoreSelf): void { context.abortSignal, ); }; - const extension = self.writable>>({ - [MCP_EXTENSION]: createMcpExtension([], call), - }); + // Code mode switched off or not loaded: the tools would be unreachable, so `tool_search` loads them, as pi falls back. + const reach = self.derive( + [self.registry(ExtensionSwitches)], + ([switches]): McpReach => (switches.get(CODEMODE_EXTENSION) === true ? "codemode" : "tool_search"), + ); + const extension = self.writable(extensionsFor({ servers: [], tools: [], reach: "codemode" }, call)); self.contribute(Extensions, extension); - self.onLive((ctx) => offerTools(view, extension, call, ctx)); + self.onLive((ctx) => offerTools(view, reach, extension, call, ctx)); leaseLocations(self, view, harness, worker); } -/** Follows the view's `servers` until `ctx` ends: a new tool list (not a status change) makes a new extension. */ +/** The extensions offering `offer`: the servers' tools and section, and `tool_search` while it reaches the tools. */ +function extensionsFor(offer: McpOffer, call: McpCall): Readonly> { + const extensions: Record = { [MCP_EXTENSION]: createMcpExtension(offer, call) }; + if (offer.reach === "tool_search" && offer.servers.length > 0) { + const search = createToolSearchExtension(); + extensions[search.name] = search; + } + return extensions; +} + +/** + * Follows the view's `servers` and `reach` until `ctx` ends: a new server or tool list (not a status change) or a new + * reach makes new extensions. + */ async function offerTools( view: Ref, + reach: Watchable, extension: Writable>>, call: McpCall, ctx: Ctx, ): Promise { - let offered = "[]"; - const offer = (servers: readonly Instance[]): void => { - const tools = serverTools(servers); - const key = JSON.stringify(tools); - if (key === offered) return; - offered = key; - extension.set({ [MCP_EXTENSION]: createMcpExtension(tools, call) }); - }; const watch = await expectVariant(view, "worker").servers.watch(ctx); try { - offer(watch.value); - watch.start(async (change) => { - if (change.kind === "value") offer(change.value); - }); - await untilAborted(ctx); + const reaches = await reach.watch(ctx); + try { + let servers = watch.value; + let current = reaches.value; + let offered = ""; + const offer = (): void => { + const next: McpOffer = { servers: serverNames(servers), tools: serverTools(servers), reach: current }; + const key = JSON.stringify(next); + if (key === offered) return; + offered = key; + extension.set(extensionsFor(next, call)); + }; + offer(); + watch.start(async (change) => { + if (change.kind !== "value") return; + servers = change.value; + offer(); + }); + reaches.start(async (change) => { + if (change.kind !== "value") return; + current = change.value; + offer(); + }); + await untilAborted(ctx); + } finally { + await reaches.stop(); + } } finally { await watch.stop(); } diff --git a/apps/p2/test/p2/mcp-tools.test.ts b/apps/p2/test/p2/mcp-tools.test.ts index 7705fa631..db582fbb6 100644 --- a/apps/p2/test/p2/mcp-tools.test.ts +++ b/apps/p2/test/p2/mcp-tools.test.ts @@ -1,13 +1,19 @@ // MCP tools as Durable tools (plugins/mcp/tools.ts): pi's names, with the hash suffix for every colliding name whatever -// the order, structured output only for tools with an output schema (error results included), and error results with -// text. +// the order, pi's exposures (code mode only by default, `tool_search` without code mode) with the `mcp_servers` section, +// structured output only for tools with an output schema (error results included), and error results with text. import assert from "node:assert/strict"; import { test } from "node:test"; import type { ToolExecutionApi, ToolRegistration } from "@earendil-works/pi-durable"; import type { CallToolResult } from "@earendil-works/pi-mcp"; import { BACKGROUND_CONTEXT } from "@p2/app/v1"; import type { McpTool } from "../../plugins/mcp/contract.ts"; -import { createMcpExtension, type McpCall, type ServerTool } from "../../plugins/mcp/tools.ts"; +import { + createMcpExtension, + MCP_SERVERS_SECTION, + type McpCall, + type McpReach, + type ServerTool, +} from "../../plugins/mcp/tools.ts"; const tool = (name: string, extra: Partial = {}): McpTool => ({ name, @@ -15,7 +21,14 @@ const tool = (name: string, extra: Partial = {}): McpTool => ({ ...extra, }); const names = (tools: readonly ServerTool[]) => - (createMcpExtension(tools, async () => ({ content: [] })).tools ?? []).map((registration) => registration.name); + (createMcpExtension(offer(tools), async () => ({ content: [] })).tools ?? []).map( + (registration) => registration.name, + ); + +/** What the worker offers for `tools`: their servers, reached from code mode unless `reach` says otherwise. */ +function offer(tools: readonly ServerTool[], reach: McpReach = "codemode") { + return { servers: [...new Set(tools.map((entry) => entry.server))].sort(), tools, reach }; +} test("tool names: pi's, and every tool of a colliding name gets the hash suffix whatever the order", () => { const plain = [ @@ -59,6 +72,44 @@ test("tool names: a collision across servers suffixes both tools too", () => { assert.equal(names([{ server: "a", tool: tool("b__c") }])[0], "mcp__a__b__c"); }); +test("exposure: as pi's default, code mode only, deferred so the codemode section leaves the tools out", async () => { + const tools = [{ server: "docs", tool: tool("search") }]; + const codemode = createMcpExtension(offer(tools), async () => ({ content: [] })); + const [scripted] = codemode.tools as ToolRegistration[]; + assert.deepEqual([scripted!.deferred, scripted!.callers], [true, ["tools"]]); + // Without code mode (pi's fallback), `tool_search` loads them for the model. + const searched = createMcpExtension(offer(tools, "tool_search"), async () => ({ content: [] })); + const [loaded] = searched.tools as ToolRegistration[]; + assert.deepEqual([loaded!.deferred, loaded!.callers], [true, undefined]); +}); + +test("the mcp_servers section names each server's namespace and how its tools are reached, as pi's", async () => { + const render = async (extension: ReturnType) => { + const [listing] = extension.sections ?? []; + assert.equal(listing?.key, MCP_SERVERS_SECTION); + // The section renders a constant: it reads nothing of its input. + return listing!.render(undefined as never, BACKGROUND_CONTEXT); + }; + const tools = [ + { server: "my-docs", tool: tool("search") }, + { server: "app", tool: tool("read") }, + ]; + assert.equal( + await render(createMcpExtension(offer(tools), async () => ({ content: [] }))), + "MCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts.\n- mcp__app (codemode)\n- mcp__my_docs (codemode)", + ); + assert.equal( + await render(createMcpExtension(offer(tools, "tool_search"), async () => ({ content: [] }))), + "MCP servers whose tools are not declared to you. Load the tools of `tool_search` servers with `tool_search`.\n- mcp__app (tool_search)\n- mcp__my_docs (tool_search)", + ); + // A server still connecting has no tools yet, and is listed; without servers there is no section. + const starting = createMcpExtension({ servers: ["slow"], tools: [], reach: "codemode" }, async () => ({ + content: [], + })); + assert.match(String(await render(starting)), /- mcp__slow \(codemode\)$/); + assert.deepEqual(createMcpExtension(offer([]), async () => ({ content: [] })).sections, []); +}); + /** The registration of `mcpTool` calling a server that answers `result`, and what it was called with. */ function registration(mcpTool: McpTool, result: CallToolResult) { const calls: Parameters[0][] = []; @@ -66,7 +117,8 @@ function registration(mcpTool: McpTool, result: CallToolResult) { calls.push(request); return result; }; - const [registered] = createMcpExtension([{ server: "docs", tool: mcpTool }], call).tools as ToolRegistration[]; + const [registered] = createMcpExtension(offer([{ server: "docs", tool: mcpTool }]), call) + .tools as ToolRegistration[]; // The tool reads only the conversation ID and the reader from the API. const api = { conversationId: 1 } as unknown as ToolExecutionApi; return { registered: registered!, calls, run: () => registered!.execute({ q: "x" }, api, BACKGROUND_CONTEXT) }; @@ -77,7 +129,6 @@ test("results: structured output only with an output schema, error results inclu const structured = { content: [{ type: "text" as const, text: "2 hits" }], structuredContent: { hits: 2 } }; const withSchema = registration(tool("search", { outputSchema: schema }), structured); assert.deepEqual(withSchema.registered.structuredOutputSchema, schema); - assert.equal(withSchema.registered.deferred, true); assert.deepEqual(await withSchema.run(), { output: [{ type: "text", text: "2 hits" }], isError: false, diff --git a/apps/p2/test/p2/mcp-worker.test.ts b/apps/p2/test/p2/mcp-worker.test.ts index 7a4d33f91..3e6e2c06c 100644 --- a/apps/p2/test/p2/mcp-worker.test.ts +++ b/apps/p2/test/p2/mcp-worker.test.ts @@ -1,11 +1,13 @@ // MCP tools in a session worker, end to end with the fake provider: the worker leases the owner's servers at the -// session's location with the project `.pi/mcp.json` it read there, `tool_search` loads the deferred MCP tool, the -// agent's call returns the server's answer, a relocation moves the lease, and a subagent's own location is leased -// while it is not archived. +// session's location with the project `.pi/mcp.json` it read there, the `mcp_servers` section lists them, a code mode +// script's call returns the server's answer (the model is not offered the tools, as in pi), a relocation moves the +// lease, a subagent's own location is leased while it is not archived, and with code mode off `tool_search` loads the +// tools for the model. import assert from "node:assert/strict"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import type { ConversationView } from "@earendil-works/pi-durable"; @@ -23,86 +25,133 @@ const results = (view: ConversationView | null | undefined): ToolResultMessage[] ); const text = (result: ToolResultMessage | undefined) => (result?.content ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); +/** The prompt sections in effect after the conversation's last request. */ +const sections = (view: ConversationView | null | undefined): Record => { + const current: Record = {}; + for (const entry of view?.entries ?? []) + for (const message of entry.model ?? []) { + if (message.role !== "system") continue; + for (const [key, value] of Object.entries(message.sections ?? {})) { + if (value === null) delete current[key]; + else current[key] = value; + } + } + return current; +}; +/** A completed code mode result's output, after its "Script completed" header. */ +const scriptOutput = (result: ToolResultMessage): string => { + const match = /^Script completed\nWall time [\d.]+ seconds\nOutput:\n\n?/.exec(text(result)); + assert.ok(match, `a completed script's result: ${text(result)}`); + return text(result).slice(match[0].length); +}; -test("a session's agent finds an MCP tool with tool_search and calls it", { timeout: 90_000 }, async (t) => { - const fixture = await startFixture(); - t.after(() => fixture.close()); - writeFileSync( - join(fixture.dataDir, "mcp.json"), - JSON.stringify({ mcpServers: { files: { command: process.execPath, args: [FIXTURE] } } }), - ); - // The project at the session's cwd adds a server there and refuses an HTTP one. - mkdirSync(join(fixture.workspace, ".pi")); - writeFileSync( - join(fixture.workspace, ".pi", "mcp.json"), - JSON.stringify({ - mcpServers: { - lint: { command: process.execPath, args: [FIXTURE] }, - remote: { url: "https://example.com/mcp" }, - }, - }), - ); - const owner = fixture.browser(); - const mcp = await holdIn(owner, Mcp, {}, "owner"); - const sessions = await holdIn(owner, Sessions, {}, "owner"); - const session = await sessions.create({ cwd: fixture.workspace, title: "MCP" }, BACKGROUND_CONTEXT); - const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant"); - // The worker leases the session's location; the owner's and the project's servers connect there. - const started = await until( - mcp.instances, - (instances) => - instances.length === 2 && - instances.every((item) => item.status === "connected" && item.location?.cwd === fixture.workspace), - ); - assert.deepEqual( - started.map((item) => [item.server, item.source]), - [ - ["files", "owner"], - ["lint", "project"], - ], - ); - const instance = started[0]; - const [refused] = await until(mcp.errors, (errors) => errors.length > 0); - assert.match(refused!, /\.pi\/mcp\.json: server "remote": HTTP servers are for the owner's mcp\.json/); - let serial = 0; - const call = async (prompt: string) => { - const count = results(await until(chat.view, Boolean)).length; - await chat.send({ text: prompt, requestId: `mcp-${++serial}` }, BACKGROUND_CONTEXT); - return results( - await until( - chat.view, - (view) => results(view).length > count && !(view?.docs["pi.live"] as { run?: unknown })?.run, - ), - ).at(-1)!; - }; - const found = await call(`tool_search ${JSON.stringify({ query: "echo" })}`); - assert.equal(found.isError, false, text(found)); - assert.match(text(found), /mcp__files__echo/); - const echoed = await call(`mcp__files__echo ${JSON.stringify({ text: "hello" })}`); - assert.equal(echoed.isError, false, text(echoed)); - assert.equal(text(echoed), "echo: hello"); - assert.deepEqual(echoed.details, { server: "files", tool: "echo" }); - // The session moves in place: the worker leases the new location (no project there) and gives up the old one. - const moved = join(fixture.workspace, "moved"); - mkdirSync(moved); - await sessions.relocate({ id: session.id, cwd: moved, env: null }, BACKGROUND_CONTEXT); - const after = await until( - mcp.instances, - (instances) => - instances.length === 1 && instances[0]!.location?.cwd === moved && instances[0]!.status === "connected", - ); - assert.notEqual(after[0]!.id, instance!.id); - assert.equal(text(await call(`mcp__files__echo ${JSON.stringify({ text: "there" })}`)), "echo: there"); - // A subagent spawned with its own cwd (a commit of the subagent registry): its location is leased too, until it is - // archived. - const own = join(moved, "own"); - mkdirSync(own); - await call( - `subagent ${JSON.stringify({ action: "spawn", name: "own", message: "hi", cwd: "own", tools: ["bash"] })}`, - ); - await until(mcp.instances, (instances) => - instances.some((item) => item.location?.cwd === own && item.status === "connected"), - ); - await call(`subagent ${JSON.stringify({ action: "archive", name: "own" })}`); - await until(mcp.instances, (instances) => instances.length === 1 && instances[0]!.location?.cwd === moved); -}); +test( + "a session's code mode scripts call MCP tools, and tool_search loads them without code mode", + { timeout: 90_000 }, + async (t) => { + const fixture = await startFixture(); + t.after(() => fixture.close()); + writeFileSync( + join(fixture.dataDir, "mcp.json"), + JSON.stringify({ mcpServers: { files: { command: process.execPath, args: [FIXTURE] } } }), + ); + // The project at the session's cwd adds a server there and refuses an HTTP one. + mkdirSync(join(fixture.workspace, ".pi")); + writeFileSync( + join(fixture.workspace, ".pi", "mcp.json"), + JSON.stringify({ + mcpServers: { + lint: { command: process.execPath, args: [FIXTURE] }, + remote: { url: "https://example.com/mcp" }, + }, + }), + ); + const owner = fixture.browser(); + const mcp = await holdIn(owner, Mcp, {}, "owner"); + const sessions = await holdIn(owner, Sessions, {}, "owner"); + const session = await sessions.create({ cwd: fixture.workspace, title: "MCP" }, BACKGROUND_CONTEXT); + const chat = await holdIn(owner, Chat, { session: session.id, conversation: 1 }, "participant"); + // The worker leases the session's location; the owner's and the project's servers connect there. + const started = await until( + mcp.instances, + (instances) => + instances.length === 2 && + instances.every((item) => item.status === "connected" && item.location?.cwd === fixture.workspace), + ); + assert.deepEqual( + started.map((item) => [item.server, item.source]), + [ + ["files", "owner"], + ["lint", "project"], + ], + ); + const instance = started[0]; + const [refused] = await until(mcp.errors, (errors) => errors.length > 0); + assert.match(refused!, /\.pi\/mcp\.json: server "remote": HTTP servers are for the owner's mcp\.json/); + let serial = 0; + const call = async (prompt: string) => { + const count = results(await until(chat.view, Boolean)).length; + await chat.send({ text: prompt, requestId: `mcp-${++serial}` }, BACKGROUND_CONTEXT); + return results( + await until( + chat.view, + (view) => results(view).length > count && !(view?.docs["pi.live"] as { run?: unknown })?.run, + ), + ).at(-1)!; + }; + // As in pi, the tools are for code mode scripts: the model is not offered them, and `tool_search` is not installed. + const echo = async (said: string) => { + const result = await call(`codemode return await tools.mcp__files__echo({ text: ${JSON.stringify(said)} });`); + assert.equal(result.isError, false, text(result)); + return scriptOutput(result); + }; + assert.equal(await echo("hello"), "echo: hello"); + assert.equal( + sections(await until(chat.view, Boolean)).mcp_servers, + "\nMCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts.\n- mcp__files (codemode)\n- mcp__lint (codemode)\n", + ); + assert.equal((await call(`mcp__files__echo ${JSON.stringify({ text: "hello" })}`)).isError, true); + assert.equal((await call(`tool_search ${JSON.stringify({ query: "echo" })}`)).isError, true); + // The session moves in place: the worker leases the new location (no project there) and gives up the old one. + const moved = join(fixture.workspace, "moved"); + mkdirSync(moved); + await sessions.relocate({ id: session.id, cwd: moved, env: null }, BACKGROUND_CONTEXT); + const after = await until( + mcp.instances, + (instances) => + instances.length === 1 && instances[0]!.location?.cwd === moved && instances[0]!.status === "connected", + ); + assert.notEqual(after[0]!.id, instance!.id); + assert.equal(await echo("there"), "echo: there"); + // A subagent spawned with its own cwd (a commit of the subagent registry): its location is leased too, until it is + // archived. + const own = join(moved, "own"); + mkdirSync(own); + await call( + `subagent ${JSON.stringify({ action: "spawn", name: "own", message: "hi", cwd: "own", tools: ["bash"] })}`, + ); + await until(mcp.instances, (instances) => + instances.some((item) => item.location?.cwd === own && item.status === "connected"), + ); + await call(`subagent ${JSON.stringify({ action: "archive", name: "own" })}`); + await until(mcp.instances, (instances) => instances.length === 1 && instances[0]!.location?.cwd === moved); + // Code mode off: as pi falls back, `tool_search` loads the tools and the model calls them. The worker's settings + // observer is asynchronous: turns go until one runs with `tool_search` installed. + writeFileSync(join(fixture.dataDir, "settings.json"), JSON.stringify({ codemode: false })); + let found: ToolResultMessage | undefined; + for (let attempt = 0; attempt < 20 && !(found && !found.isError); attempt++) { + if (attempt > 0) await sleep(200); + found = await call(`tool_search ${JSON.stringify({ query: "echo" })}`); + } + assert.equal(found?.isError, false, text(found)); + assert.match(text(found), /mcp__files__echo/); + assert.match( + sections(await until(chat.view, Boolean)).mcp_servers ?? "", + /- mcp__files \(tool_search\)\n<\/mcp_servers>$/, + ); + const echoed = await call(`mcp__files__echo ${JSON.stringify({ text: "hello" })}`); + assert.equal(echoed.isError, false, text(echoed)); + assert.equal(text(echoed), "echo: hello"); + assert.deepEqual(echoed.details, { server: "files", tool: "echo" }); + }, +);