From ada0955334b84bce945d78dbe3cbbfebddc71132 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Sat, 11 Jul 2026 08:56:42 -0700 Subject: [PATCH] fix(server): preserve Pi MCP config during injection (#1990) --- docs/providers.md | 2 +- .../server/agent/providers/pi/agent.test.ts | 47 +++++++++- .../src/server/agent/providers/pi/agent.ts | 89 ++++++++++++++++--- 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index a0da9fa3a..e9cdc8c09 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -28,7 +28,7 @@ Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append 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. 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 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. Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`. 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 3f1ef5d3f..4372f0b0a 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -12,7 +12,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js"; @@ -550,12 +550,14 @@ describe("PiRpcAgentSession", () => { }, }, {}, + { env: { RESUME_PROBE: "expected" } }, ); expect(pi.recordedLaunches).toHaveLength(1); const actualLaunch = pi.recordedLaunches[0]!; expect(actualLaunch).toMatchObject({ cwd: "/workspace/project", + env: { RESUME_PROBE: "expected" }, session: "/tmp/native-pi-session", }); expect(actualLaunch.extensionPaths).toHaveLength(1); @@ -1221,7 +1223,21 @@ describe("PiRpcAgentClient", () => { expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]); }); - test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => { + test("injects MCP servers without replacing the Pi global MCP config", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); + onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); + writeFileSync( + path.join(agentDir, "mcp.json"), + JSON.stringify({ + settings: { toolPrefix: "none", disableProxyTool: true }, + "mcp-servers": { + "brave-search": { + url: "https://example.com/mcp/brave", + directTools: ["brave_llm_context"], + }, + }, + }), + ); const pi = new FakePi(); pi.queueCommands([ { @@ -1248,6 +1264,7 @@ describe("PiRpcAgentClient", () => { }, }, }), + { env: { PI_CODING_AGENT_DIR: agentDir } }, ); expect(pi.recordedLaunches).toHaveLength(2); @@ -1276,7 +1293,12 @@ describe("PiRpcAgentClient", () => { mcpServers: Record; }; expect(injectedConfig).toEqual({ + settings: { toolPrefix: "none", disableProxyTool: true }, mcpServers: { + "brave-search": { + url: "https://example.com/mcp/brave", + directTools: ["brave_llm_context"], + }, paseo: { url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", auth: false, @@ -1294,6 +1316,27 @@ describe("PiRpcAgentClient", () => { expect(existsSync(configPath!)).toBe(false); }); + test("reports the path of a malformed Pi global MCP config", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); + onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); + const configPath = path.join(agentDir, "mcp.json"); + writeFileSync(configPath, "{ invalid"); + const pi = new FakePi(); + pi.queueCommands([{ name: "mcp", source: "extension" }]); + const client = createClient(pi); + + await expect( + client.createSession( + createConfig({ + mcpServers: { + paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" }, + }, + }), + { env: { PI_CODING_AGENT_DIR: agentDir } }, + ), + ).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`); + }); + 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 ec16b765e..9d0bece60 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; import { z } from "zod"; @@ -497,14 +497,63 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { }; } -function createPiMcpConfigFile(servers: Record): PiMcpConfigFile { - const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); - const filePath = join(dir, "mcp.json"); - const mcpServers: Record = {}; +function resolvePiAgentDir(env: Record | undefined): string { + // Match pi-mcp-adapter's agent-directory resolution so we preserve the config it replaces. + const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); + if (!configured) { + return join(homedir(), ".pi", "agent"); + } + if (configured === "~") { + return homedir(); + } + if (configured.startsWith("~/")) { + return resolvePath(homedir(), configured.slice(2)); + } + return resolvePath(configured); +} + +function createPiMcpConfigFile( + servers: Record, + env: Record | undefined, +): PiMcpConfigFile { + // pi-mcp-adapter treats --mcp-config as a replacement for its Pi global layer, not an + // additional layer. Rebuild that layer here; the adapter still loads shared and project files. + const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json"); + let globalConfig: unknown = {}; + if (existsSync(globalConfigPath)) { + const contents = readFileSync(globalConfigPath, "utf8"); + try { + globalConfig = JSON.parse(contents) as unknown; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Failed to parse Pi MCP config: ${globalConfigPath}`, { cause: error }); + } + throw error; + } + } + if (!isRecord(globalConfig)) { + throw new Error(`Pi MCP config must contain a JSON object: ${globalConfigPath}`); + } + let configuredServers: Record = {}; + if (isRecord(globalConfig.mcpServers)) { + configuredServers = globalConfig.mcpServers; + } else if (isRecord(globalConfig["mcp-servers"])) { + configuredServers = globalConfig["mcp-servers"]; + } + const mcpServers: Record = { ...configuredServers }; for (const [name, serverConfig] of Object.entries(servers)) { mcpServers[name] = toPiMcpConfig(serverConfig); } - writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8"); + + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); + const filePath = join(dir, "mcp.json"); + const mergedConfig: Record = { ...globalConfig, mcpServers }; + // Emit one canonical server key so a stale alias cannot shadow the injected definitions. + delete mergedConfig["mcp-servers"]; + writeFileSync(filePath, `${JSON.stringify(mergedConfig, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); return { path: filePath, cleanup: () => rmSync(dir, { recursive: true, force: true }), @@ -1911,7 +1960,10 @@ export class PiRpcAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers); + const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, { + ...this.runtimeSettings?.env, + ...launchContext?.env, + }); const paseoExtension = createPiPaseoExtensionFile(); let runtimeSession: PiRuntimeSession; try { @@ -1953,7 +2005,7 @@ export class PiRpcAgentClient implements AgentClient { async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, - _launchContext?: AgentLaunchContext, + launchContext?: AgentLaunchContext, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -1963,12 +2015,20 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides); - const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers); + const mcpConfig = await this.prepareMcpConfig( + resumeConfig.cwd, + resumeConfig.config.mcpServers, + { + ...this.runtimeSettings?.env, + ...launchContext?.env, + }, + ); const paseoExtension = createPiPaseoExtensionFile(); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ cwd: resumeConfig.cwd, + env: launchContext?.env, session: sessionFile, model: resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined, @@ -2079,18 +2139,19 @@ export class PiRpcAgentClient implements AgentClient { private async prepareMcpConfig( cwd: string, servers: Record | undefined, + env: Record | undefined, ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } - if (!(await this.detectMcpAdapter(cwd))) { + if (!(await this.detectMcpAdapter(cwd, env))) { return null; } - return createPiMcpConfigFile(servers); + return createPiMcpConfigFile(servers, env); } - private async detectMcpAdapter(cwd: string): Promise { - const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => { + private async detectMcpAdapter(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"); return null; });