fix(server): preserve Pi MCP config during injection (#1990)

This commit is contained in:
Matt Cowger
2026-07-11 08:56:42 -07:00
committed by GitHub
parent ca5c786ccf
commit ada0955334
3 changed files with 121 additions and 17 deletions

View File

@@ -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 `<Pi agent dir>/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`.

View File

@@ -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<string, unknown>;
};
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([]);

View File

@@ -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<string, McpServerConfig>): PiMcpConfigFile {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
const filePath = join(dir, "mcp.json");
const mcpServers: Record<string, PiMcpServerConfig> = {};
function resolvePiAgentDir(env: Record<string, string> | 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<string, McpServerConfig>,
env: Record<string, string> | 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<string, unknown> = {};
if (isRecord(globalConfig.mcpServers)) {
configuredServers = globalConfig.mcpServers;
} else if (isRecord(globalConfig["mcp-servers"])) {
configuredServers = globalConfig["mcp-servers"];
}
const mcpServers: Record<string, unknown> = { ...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<string, unknown> = { ...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<AgentSession> {
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<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
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<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined,
): Promise<PiMcpConfigFile | null> {
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<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
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;
});