mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(server): preserve Pi MCP config during injection (#1990)
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user