diff --git a/docs/providers.md b/docs/providers.md index 917c2512d1..6373caa57c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -103,7 +103,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead. -Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config. +Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence. + +The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config. Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines. diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 0130574211..29c515ad76 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -160,6 +160,7 @@ function parseEntryCapture(notification: string): unknown { async function loadPaseoExtensionListeners( extensionPath: string, + registerMcpServer: (name: string, config: unknown) => void = () => undefined, ): Promise> { const listeners = new Map(); const extension = (await import(pathToFileURL(extensionPath).href)) as { @@ -167,16 +168,26 @@ async function loadPaseoExtensionListeners( on: (event: string, listener: PaseoExtensionListener) => void; events: { on: () => void }; registerCommand: () => void; + registerMcpServer: (name: string, config: unknown) => void; }) => void; }; extension.default({ on: (event, listener) => listeners.set(event, listener), events: { on: () => undefined }, registerCommand: () => undefined, + registerMcpServer, }); return listeners; } +async function readRegisteredMcpServers(extensionPath: string): Promise> { + const servers: Record = {}; + await loadPaseoExtensionListeners(extensionPath, (name, config) => { + servers[name] = config; + }); + return servers; +} + async function applyPaseoExtensionSystemPrompt( extensionPath: string, systemPrompt: string, @@ -3129,6 +3140,102 @@ describe("PiRpcAgentClient", () => { ).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`); }); + test("registers MCP servers through Pi's built-in MCP when pi-mcp-adapter is not loaded", async () => { + const pi = new FakePi(); + pi.queueCommands([ + { + name: "mcp", + source: "extension", + sourceInfo: { path: "builtin:mcp", source: "builtin" }, + }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { type: "stdio", command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }, + }), + ); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).not.toContain("--mcp-config"); + const extensionPath = actualLaunch.extensionPaths[0]!; + expect(await readRegisteredMcpServers(extensionPath)).toEqual({ + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }); + + await session.close(); + expect(existsSync(extensionPath)).toBe(false); + }); + + test("registers the resumed session's MCP servers through Pi's built-in MCP", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const original = await client.createSession(createConfig()); + const persistence = original.describePersistence()!; + await original.close(); + + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + const session = await client.resumeSession(persistence, { + mcpServers: { + paseo: { type: "http", url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }, + }); + onTestFinished(() => session.close()); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches.at(-1)!; + expect(actualLaunch.session).toBe(persistence.nativeHandle); + expect(actualLaunch.argv).not.toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({ + paseo: { url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }); + }); + + test("uses pi-mcp-adapter when it replaces Pi's built-in MCP", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); + onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); + const pi = new FakePi(); + pi.queueCommands([ + { name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }, + { name: "mcp:1", source: "extension", sourceInfo: { source: "npm:pi-mcp-adapter" } }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" } }, + }), + { env: { PI_CODING_AGENT_DIR: agentDir } }, + ); + onTestFinished(() => session.close()); + + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({}); + }); + + test.each([ + { name: "legacy", config: { type: "sse" as const, url: "https://example.com/sse" } }, + { name: "foo.bar", config: { type: "http" as const, url: "https://example.com/mcp" } }, + ])("rejects $name, which Pi's built-in MCP cannot register", async ({ name, config }) => { + const pi = new FakePi(); + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + + await expect( + createClient(pi).createSession(createConfig({ mcpServers: { [name]: config } })), + ).rejects.toThrow(name); + expect(pi.recordedLaunches).toHaveLength(1); + }); + test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => { const pi = new FakePi(); pi.queueCommands([]); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 9a37c9adc7..1d8b969030 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -262,13 +262,17 @@ interface PiMcpServerConfig { env?: Record; url?: string; headers?: Record; - auth?: false; - oauth?: false; } -interface PiMcpConfigFile { - path: string; - cleanup: () => void; +// Pi's built-in MCP extension takes servers from Paseo's extension; pi-mcp-adapter replaces it +// and takes them from a --mcp-config file. +type PiMcpInjection = + | { kind: "builtin"; servers: Record } + | { kind: "adapter"; configFile: PiTempFile }; + +interface PiPaseoExtensionOptions { + systemPrompt?: string; + mcpServers?: Record; } interface PiTempFile { @@ -487,7 +491,7 @@ function buildResumeStartInput(input: { resumeConfig: PiResumeConfig; sessionFile: string; launchContext: AgentLaunchContext | undefined; - mcpConfig: PiMcpConfigFile | null; + mcpConfigFile: PiTempFile | null; paseoExtension: PiTempFile | null; }): PiStartSessionInput { return { @@ -496,7 +500,7 @@ function buildResumeStartInput(input: { session: input.sessionFile, model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, - mcpConfigPath: input.mcpConfig?.path, + mcpConfigPath: input.mcpConfigFile?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, }; } @@ -513,11 +517,36 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { return { url: config.url, ...(config.headers ? { headers: config.headers } : {}), - auth: false, - oauth: false, }; } +function toPiMcpAdapterConfig( + config: McpServerConfig, +): PiMcpServerConfig & { auth?: false; oauth?: false } { + const piConfig = toPiMcpConfig(config); + return config.type === "stdio" ? piConfig : { ...piConfig, auth: false, oauth: false }; +} + +function toPiBuiltinMcpServers( + servers: Record, +): Record { + const piServers: Record = {}; + for (const [name, config] of Object.entries(servers)) { + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + throw new Error( + `Pi's built-in MCP cannot register server "${name}": names may only contain letters, digits, "_", and "-".`, + ); + } + if (config.type === "sse") { + throw new Error( + `Pi's built-in MCP cannot register SSE server "${name}": use streamable HTTP or load pi-mcp-adapter.`, + ); + } + piServers[name] = toPiMcpConfig(config); + } + return piServers; +} + function resolvePiAgentDir(env: Record | undefined): string { const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); if (!configured) { @@ -558,7 +587,7 @@ function createPiMcpConfigFile( options?: { piGlobalConfigEnv?: Record; }, -): PiMcpConfigFile { +): PiTempFile { const globalConfig = options?.piGlobalConfigEnv ? readPiGlobalMcpConfig(options.piGlobalConfigEnv) : {}; @@ -570,7 +599,7 @@ function createPiMcpConfigFile( } const mcpServers: Record = { ...configuredServers }; for (const [name, serverConfig] of Object.entries(servers)) { - mcpServers[name] = toPiMcpConfig(serverConfig); + mcpServers[name] = toPiMcpAdapterConfig(serverConfig); } const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); @@ -587,7 +616,10 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { +function createPiPaseoExtensionFile({ + systemPrompt, + mcpServers = {}, +}: PiPaseoExtensionOptions): PiTempFile { const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); const filePath = join(dir, "paseo-integration.mjs"); writeFileSync( @@ -647,6 +679,9 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { } export default function paseoIntegration(pi) { + for (const [name, config] of Object.entries(${JSON.stringify(mcpServers)})) { + pi.registerMcpServer(name, config); + } const submittedUserMessages = []; ${piExtensionRuntimeBridge} @@ -728,7 +763,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { }); } `.trimStart(), - "utf8", + // MCP server headers and env can carry credentials. + { encoding: "utf8", mode: 0o600 }, ); return { path: filePath, @@ -758,6 +794,14 @@ function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { return JSON.stringify(command.sourceInfo).includes("pi-mcp-adapter"); } +function isPiBuiltinMcpCommand(command: PiRpcSlashCommand): boolean { + return ( + command.source === "extension" && + command.name === "mcp" && + command.sourceInfo?.path === "builtin:mcp" + ); +} + function withPiCapabilities(supportsMcpServers: boolean): AgentCapabilityFlags { return { ...PI_CAPABILITIES, @@ -2445,10 +2489,12 @@ export class PiRpcAgentClient implements AgentClient { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), - ); + const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv); + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ @@ -2457,11 +2503,11 @@ export class PiRpcAgentClient implements AgentClient { thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined, noSession: config.internal === true, env: launchContext?.env, - mcpConfigPath: mcpConfig?.path, + mcpConfigPath: mcpConfigFile?.path, extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, }); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2470,15 +2516,15 @@ export class PiRpcAgentClient implements AgentClient { runtimeSession, config, initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2501,17 +2547,19 @@ export class PiRpcAgentClient implements AgentClient { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig( + const mcp = await this.prepareMcpInjection( resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, ); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts( + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts( resumeConfig.config.systemPrompt, resumeConfig.config.daemonAppendSystemPrompt, ), - ); + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession( @@ -2519,12 +2567,12 @@ export class PiRpcAgentClient implements AgentClient { resumeConfig, sessionFile, launchContext, - mcpConfig, + mcpConfigFile, paseoExtension, }), ); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2533,15 +2581,15 @@ export class PiRpcAgentClient implements AgentClient { runtimeSession, config: resumeConfig.config, initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2656,33 +2704,47 @@ export class PiRpcAgentClient implements AgentClient { } } - private async prepareMcpConfig( + private async prepareMcpInjection( cwd: string, servers: Record | undefined, env: Record | undefined, - ): Promise { + ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } - if (!(await this.detectMcpAdapter(cwd, env))) { - return null; + const mcpSupport = await this.detectMcpSupport(cwd, env); + if (mcpSupport === "builtin") { + return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) }; } - return createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }); + if (mcpSupport === "adapter") { + return { + kind: "adapter", + configFile: createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }), + }; + } + return null; } - private async detectMcpAdapter(cwd: string, env?: Record): Promise { + private async detectMcpSupport( + cwd: string, + env?: Record, + ): Promise { const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start"); + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed to start"); return null; }); if (!runtimeSession) { - return false; + return null; } try { - return (await runtimeSession.getCommands()).some(isPiMcpAdapterCommand); + const commands = await runtimeSession.getCommands(); + if (commands.some(isPiMcpAdapterCommand)) { + return "adapter"; + } + return commands.some(isPiBuiltinMcpCommand) ? "builtin" : null; } catch (error) { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed"); - return false; + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed"); + return null; } finally { await runtimeSession.close().catch(() => undefined); }