fix(server): preserve Pi MCP config during injection (#1990)
This commit is contained in:
+1
-1
@@ -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 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`.
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pino from "pino";
|
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 type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
||||||
@@ -550,12 +550,14 @@ describe("PiRpcAgentSession", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
{ env: { RESUME_PROBE: "expected" } },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toHaveLength(1);
|
expect(pi.recordedLaunches).toHaveLength(1);
|
||||||
const actualLaunch = pi.recordedLaunches[0]!;
|
const actualLaunch = pi.recordedLaunches[0]!;
|
||||||
expect(actualLaunch).toMatchObject({
|
expect(actualLaunch).toMatchObject({
|
||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
|
env: { RESUME_PROBE: "expected" },
|
||||||
session: "/tmp/native-pi-session",
|
session: "/tmp/native-pi-session",
|
||||||
});
|
});
|
||||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
@@ -1221,7 +1223,21 @@ describe("PiRpcAgentClient", () => {
|
|||||||
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
|
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();
|
const pi = new FakePi();
|
||||||
pi.queueCommands([
|
pi.queueCommands([
|
||||||
{
|
{
|
||||||
@@ -1248,6 +1264,7 @@ describe("PiRpcAgentClient", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toHaveLength(2);
|
expect(pi.recordedLaunches).toHaveLength(2);
|
||||||
@@ -1276,7 +1293,12 @@ describe("PiRpcAgentClient", () => {
|
|||||||
mcpServers: Record<string, unknown>;
|
mcpServers: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(injectedConfig).toEqual({
|
expect(injectedConfig).toEqual({
|
||||||
|
settings: { toolPrefix: "none", disableProxyTool: true },
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
|
"brave-search": {
|
||||||
|
url: "https://example.com/mcp/brave",
|
||||||
|
directTools: ["brave_llm_context"],
|
||||||
|
},
|
||||||
paseo: {
|
paseo: {
|
||||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||||
auth: false,
|
auth: false,
|
||||||
@@ -1294,6 +1316,27 @@ describe("PiRpcAgentClient", () => {
|
|||||||
expect(existsSync(configPath!)).toBe(false);
|
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 () => {
|
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
|
||||||
const pi = new FakePi();
|
const pi = new FakePi();
|
||||||
pi.queueCommands([]);
|
pi.queueCommands([]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
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 { homedir, tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join, resolve as resolvePath } from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -497,14 +497,63 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpConfigFile {
|
function resolvePiAgentDir(env: Record<string, string> | undefined): string {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
|
// Match pi-mcp-adapter's agent-directory resolution so we preserve the config it replaces.
|
||||||
const filePath = join(dir, "mcp.json");
|
const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||||
const mcpServers: Record<string, PiMcpServerConfig> = {};
|
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)) {
|
for (const [name, serverConfig] of Object.entries(servers)) {
|
||||||
mcpServers[name] = toPiMcpConfig(serverConfig);
|
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 {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
||||||
@@ -1911,7 +1960,10 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): 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();
|
const paseoExtension = createPiPaseoExtensionFile();
|
||||||
let runtimeSession: PiRuntimeSession;
|
let runtimeSession: PiRuntimeSession;
|
||||||
try {
|
try {
|
||||||
@@ -1953,7 +2005,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
_launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const sessionFile = handle.nativeHandle;
|
const sessionFile = handle.nativeHandle;
|
||||||
if (!sessionFile) {
|
if (!sessionFile) {
|
||||||
@@ -1963,12 +2015,20 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
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();
|
const paseoExtension = createPiPaseoExtensionFile();
|
||||||
let runtimeSession: PiRuntimeSession;
|
let runtimeSession: PiRuntimeSession;
|
||||||
try {
|
try {
|
||||||
runtimeSession = await this.runtime.startSession({
|
runtimeSession = await this.runtime.startSession({
|
||||||
cwd: resumeConfig.cwd,
|
cwd: resumeConfig.cwd,
|
||||||
|
env: launchContext?.env,
|
||||||
session: sessionFile,
|
session: sessionFile,
|
||||||
model: resumeConfig.model,
|
model: resumeConfig.model,
|
||||||
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
|
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
|
||||||
@@ -2079,18 +2139,19 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
private async prepareMcpConfig(
|
private async prepareMcpConfig(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
servers: Record<string, McpServerConfig> | undefined,
|
servers: Record<string, McpServerConfig> | undefined,
|
||||||
|
env: Record<string, string> | undefined,
|
||||||
): Promise<PiMcpConfigFile | null> {
|
): Promise<PiMcpConfigFile | null> {
|
||||||
if (!servers || Object.keys(servers).length === 0) {
|
if (!servers || Object.keys(servers).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!(await this.detectMcpAdapter(cwd))) {
|
if (!(await this.detectMcpAdapter(cwd, env))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createPiMcpConfigFile(servers);
|
return createPiMcpConfigFile(servers, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async detectMcpAdapter(cwd: string): Promise<boolean> {
|
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
|
||||||
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
|
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 adapter probe failed to start");
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user