mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): connect MCP servers and expose them through codemode
Adds a built-in mcp extension that reads mcp.json (agent dir, and .pi/mcp.json for trusted projects) in the common mcpServers format, connects stdio and streamable HTTP servers at session start, and registers their tools as mcp__<server>__<tool>. Calls go through the regular tool pipeline, so hooks and permission extensions apply. /mcp shows server status. By default a server's tools are codemode-only: the new nestedOnly tool flag keeps them executable for other tools but out of the model's tool declarations, and pi activates the codemode tool for them. "exposure": "direct" declares them to the model as well. structuredContent from tools with an outputSchema reaches codemode scripts as data.
This commit is contained in:
parent
5d29fad158
commit
27d2914457
22 changed files with 1000 additions and 12 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<PreparedToolCall | ImmediateToolCallOutcome> {
|
||||
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`),
|
||||
|
|
|
|||
|
|
@ -517,6 +517,12 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|||
) => Promise<AgentToolResult<TDetails>>;
|
||||
/** 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<TParameters extends TSchema = TSchema, TDetails = any
|
|||
export interface AgentContext {
|
||||
/** Transcript visible to the model. */
|
||||
messages: AgentMessage[];
|
||||
/** Tools available for execution in this run. */
|
||||
/** Tools available for execution in this run. Tools marked `nestedOnly` are not declared to the model. */
|
||||
tools?: AgentTool<any>[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof schema> = {
|
||||
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<typeof schema> = {
|
||||
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<any>, 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<AgentEvent, { type: "tool_execution_end" }> =>
|
||||
event.type === "tool_execution_end" && event.toolCallId === "direct",
|
||||
);
|
||||
expect(directEnd?.isError).toBe(true);
|
||||
expect(directEnd?.result.content[0].text).toBe("Tool hidden not found");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ The agent directory is shown as `<agent-dir>` below. Set its location with the `
|
|||
|---|---|
|
||||
| `<agent-dir>/settings.json` | User-level [settings](settings.md), including preferences, defaults, resource paths, and Pi package declarations. |
|
||||
| `<agent-dir>/keybindings.json` | Custom terminal UI and application [keybindings](keybindings.md). |
|
||||
| `<agent-dir>/mcp.json` | [MCP servers](mcp.md) available in every project. |
|
||||
| `<agent-dir>/models.json` | [Compatible endpoints, models, and model overrides](models.md#configure-a-compatible-endpoint). |
|
||||
| `<agent-dir>/auth.json` | Saved API keys and OAuth credentials. |
|
||||
| `<agent-dir>/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 `<agent-dir>` 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. |
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@
|
|||
{
|
||||
"title": "Use Pi Packages",
|
||||
"path": "packages.md"
|
||||
},
|
||||
{
|
||||
"title": "Connect MCP Servers",
|
||||
"path": "mcp.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
46
packages/coding-agent/docs/mcp.md
Normal file
46
packages/coding-agent/docs/mcp.md
Normal file
|
|
@ -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__<server>__<tool>`. 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
packages/coding-agent/install-lock/package-lock.json
generated
12
packages/coding-agent/install-lock/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
packages/coding-agent/npm-shrinkwrap.json
generated
12
packages/coding-agent/npm-shrinkwrap.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
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))),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -497,6 +497,12 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
|||
*/
|
||||
outputSchema?: TSchema;
|
||||
|
||||
/**
|
||||
* Only callable from other tools (for example codemode scripts) through `ctx.executeTool()`.
|
||||
* The tool is not declared to the model and is left out of the system prompt.
|
||||
*/
|
||||
nestedOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Per-tool execution mode override.
|
||||
* - "sequential": this tool must execute one at a time with other tool calls.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function wrapToolDefinition<TDetails = unknown>(
|
|||
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<any>): ToolDef
|
|||
description: tool.description,
|
||||
parameters: tool.parameters as any,
|
||||
outputSchema: tool.outputSchema,
|
||||
nestedOnly: tool.nestedOnly,
|
||||
constrainedSampling: tool.constrainedSampling,
|
||||
prepareArguments: tool.prepareArguments,
|
||||
executionMode: tool.executionMode,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type TrustFile = Record<string, boolean | null | undefined>;
|
|||
|
||||
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
|
||||
"settings.json",
|
||||
"mcp.json",
|
||||
"extensions",
|
||||
"skills",
|
||||
"prompts",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
143
packages/coding-agent/src/extensions/mcp/config.ts
Normal file
143
packages/coding-agent/src/extensions/mcp/config.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* MCP server configuration.
|
||||
*
|
||||
* Servers are read from `mcp.json` in the agent directory and, for trusted projects, from
|
||||
* `<project>/.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<string, string>;
|
||||
/** 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<string, string>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
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<string, McpServerEntry>, 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<string, McpServerEntry>();
|
||||
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 };
|
||||
}
|
||||
252
packages/coding-agent/src/extensions/mcp/index.ts
Normal file
252
packages/coding-agent/src/extensions/mcp/index.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* Built-in MCP integration.
|
||||
*
|
||||
* Connects the servers from `mcp.json` when a session starts and registers their tools as
|
||||
* `mcp__<server>__<tool>`. 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<string, string> = {};
|
||||
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<McpClient> | 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<McpClient> {
|
||||
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<McpClient> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown> | 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();
|
||||
127
packages/coding-agent/src/extensions/mcp/tools.ts
Normal file
127
packages/coding-agent/src/extensions/mcp/tools.ts
Normal file
|
|
@ -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<string, unknown>, options: McpRequestOptions): Promise<CallToolResult>;
|
||||
}
|
||||
|
||||
/** `mcp__<server>__<tool>`, 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<McpToolDetails> {
|
||||
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<string, unknown>): 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<McpToolCaller>;
|
||||
}): ToolDefinition<TSchema, McpToolDetails> {
|
||||
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<string, unknown>, {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
95
packages/coding-agent/test/mcp-extension.test.ts
Normal file
95
packages/coding-agent/test/mcp-extension.test.ts
Normal file
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
198
packages/coding-agent/test/suite/agent-session-mcp.test.ts
Normal file
198
packages/coding-agent/test/suite/agent-session-mcp.test.ts
Normal file
|
|
@ -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<image>");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue