fix(pi): give Pi's built-in MCP the agent's MCP servers (#5762)

Pi 0.99 ships MCP as a built-in extension. Paseo only recognized
pi-mcp-adapter's /mcp command, so a Pi agent without the adapter started
without Paseo's MCP servers. Pi 0.99 also rejects the adapter's
--mcp-config flag, so the servers are now registered with
pi.registerMcpServer from Paseo's per-session extension. Sessions that
load pi-mcp-adapter keep the --mcp-config path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ArietidsZ <69024461+ArietidsZ@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-30 16:08:18 +02:00
committed by GitHub
co-authored by ArietidsZ
parent 8332f4d1ff
commit 4417d7c47a
3 changed files with 214 additions and 43 deletions
+3 -1
View File
@@ -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 `<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.
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 `<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 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.
@@ -160,6 +160,7 @@ function parseEntryCapture(notification: string): unknown {
async function loadPaseoExtensionListeners(
extensionPath: string,
registerMcpServer: (name: string, config: unknown) => void = () => undefined,
): Promise<Map<string, PaseoExtensionListener>> {
const listeners = new Map<string, PaseoExtensionListener>();
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<Record<string, unknown>> {
const servers: Record<string, unknown> = {};
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([]);
@@ -262,13 +262,17 @@ interface PiMcpServerConfig {
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
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<string, PiMcpServerConfig> }
| { kind: "adapter"; configFile: PiTempFile };
interface PiPaseoExtensionOptions {
systemPrompt?: string;
mcpServers?: Record<string, PiMcpServerConfig>;
}
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<string, McpServerConfig>,
): Record<string, PiMcpServerConfig> {
const piServers: Record<string, PiMcpServerConfig> = {};
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<string, string> | 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<string, string>;
},
): PiMcpConfigFile {
): PiTempFile {
const globalConfig = options?.piGlobalConfigEnv
? readPiGlobalMcpConfig(options.piGlobalConfigEnv)
: {};
@@ -570,7 +599,7 @@ function createPiMcpConfigFile(
}
const mcpServers: Record<string, unknown> = { ...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<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined,
): Promise<PiMcpConfigFile | null> {
): Promise<PiMcpInjection | null> {
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<string, string>): Promise<boolean> {
private async detectMcpSupport(
cwd: string,
env?: Record<string, string>,
): Promise<PiMcpInjection["kind"] | null> {
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);
}