mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(p2): expose MCP tools to code mode only, as pi does
MCP tools are deferred and callable only by other tools, so code mode scripts call them and neither the model nor tool_search sees them. While code mode is off or its plugin is not loaded, tool_search loads them instead, as pi falls back. The mcp_servers prompt section, as pi's, names the servers and how their tools are reached.
This commit is contained in:
parent
9f7d909f8b
commit
1bc7076b25
6 changed files with 338 additions and 133 deletions
|
|
@ -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.<name>`, 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
|
||||
|
|
|
|||
|
|
@ -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 (`<P2_DATA_DIR>/mcp.json`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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__<server>__<tool>`), 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__<server>__<tool>`), 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<string, number>();
|
||||
for (const { server, tool } of tools) {
|
||||
const name = createMcpToolName(server, tool.name);
|
||||
plain.set(name, (plain.get(name) ?? 0) + 1);
|
||||
}
|
||||
const assigned = new Set<string>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<Readonly<Record<string, Extension>>>({
|
||||
[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<Record<string, Extension>> {
|
||||
const extensions: Record<string, Extension> = { [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<typeof Mcp>,
|
||||
reach: Watchable<McpReach>,
|
||||
extension: Writable<Readonly<Record<string, Extension>>>,
|
||||
call: McpCall,
|
||||
ctx: Ctx,
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {}): McpTool => ({
|
||||
name,
|
||||
|
|
@ -15,7 +21,14 @@ const tool = (name: string, extra: Partial<McpTool> = {}): 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<typeof createMcpExtension>) => {
|
||||
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<McpCall>[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,
|
||||
|
|
|
|||
|
|
@ -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<string, string> => {
|
||||
const current: Record<string, string> = {};
|
||||
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,
|
||||
"<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</mcp_servers>",
|
||||
);
|
||||
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" });
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue