pi-mono/packages/coding-agent/test/suite/agent-session-mcp-oauth.test.ts
Armin Ronacher 8562bcf66a feat(coding-agent): codemode and MCP
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs
model-written JavaScript in a QuickJS wasm VM inside a worker. From there the
script calls pi's tools as async functions, uses a per-session store, and can
read the model catalog and run classifiers. The codemode tool in coding-agent
is compatible with Codex's exec tool, and codemode.mode works like Codex's
tool modes (on or only). Large results are written to a JSON temp file. MCP
(packages/mcp) is a standalone client for stdio and streamable HTTP servers,
with OAuth.

The core only gets general mechanisms; codemode, tool_search and MCP are
built-in extensions that use them. Other extensions can replace these
built-ins. Tools declare an exposure: direct, model-only, codemode, deferred
or hidden. prepareLoadout() lets an orchestrating tool change which tool
declarations the model sees. ctx.executeTool() runs nested calls through
validation and the tool_call/tool_result hooks. Those calls emit events with
parentToolCallId and are recorded as bounded nestedCalls on the parent
result, which compaction and HTML export use. Tools can also return
structuredContent with an outputSchema, and can report errors with isError
instead of throwing. MCP servers come from mcp.json (global, or per project
after the project is trusted) or from pi.registerMcpServer(). They are
managed with /mcp and pi mcp list|login|logout, and their runtimes load on
first use.

Closes #10040
2026-09-29 09:12:50 +02:00

185 lines
8.3 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { type AddressInfo, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import type { ToolResultMessage } from "@earendil-works/pi-ai/compat";
import { afterEach, describe, expect, it } from "vitest";
import { InMemoryAuthStorageBackend } from "../../src/core/auth-storage.ts";
import { runMcpCommand } from "../../src/extensions/mcp/cli.ts";
import type { McpOAuthConfig, McpServerEntry } from "../../src/extensions/mcp/config.ts";
import { createMcpExtension } from "../../src/extensions/mcp/index.ts";
import { McpOAuthCredentialStore } from "../../src/extensions/mcp/oauth.ts";
import { createHarness, createTestUiContext, getMessageText, type Harness } from "./harness.ts";
import { startOAuthMcpServer } from "./mcp-oauth-server.ts";
describe("AgentSession MCP OAuth", () => {
const cleanups: (() => Promise<void> | void)[] = [];
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
async function setup(browser: "follow" | "paste", oauth?: McpOAuthConfig) {
const server = await startOAuthMcpServer();
cleanups.push(server.close);
const backend = new InMemoryAuthStorageBackend();
const entry: McpServerEntry = {
name: "issues",
config: { url: server.url, exposure: "direct", ...(oauth ? { oauth } : {}) },
source: "test",
};
const notifications: string[] = [];
const opened: URL[] = [];
let redirectLocation: Promise<string> | undefined;
const harness: Harness = await createHarness({
initialActiveToolNames: [],
extensionFactories: [
createMcpExtension({
loadConfig: () => ({ servers: [entry], errors: [] }),
credentials: new McpOAuthCredentialStore(backend),
openUrl: (url) => {
opened.push(new URL(url));
if (browser === "follow") {
// The browser follows the authorization redirect to the loopback callback.
void fetch(url);
} else {
// The browser cannot reach the callback; the user pastes the redirect URL.
redirectLocation = fetch(url, { redirect: "manual" }).then(
(response) => response.headers.get("location") ?? "",
);
}
},
}),
],
});
cleanups.push(() => harness.cleanup());
await harness.session.bindExtensions({
uiContext: createTestUiContext({
notify: (message) => notifications.push(message),
// The paste prompt waits until sign-in completes unless the user pastes the redirect URL.
input: (_title, _placeholder, opts) =>
browser === "paste"
? Promise.resolve(redirectLocation)
: new Promise((resolve) =>
opts?.signal?.addEventListener("abort", () => resolve(undefined), { once: true }),
),
}),
});
return { harness, server, notifications, backend, opened };
}
async function callWhoami(harness: Harness): Promise<ToolResultMessage> {
harness.setResponses([
fauxAssistantMessage([fauxToolCall("mcp__issues__whoami", {})], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
const before = harness.session.messages.length;
await harness.session.prompt("who am i");
const result = harness.session.messages
.slice(before)
.find((message): message is ToolResultMessage => message.role === "toolResult");
if (!result) throw new Error("no tool result");
return result;
}
it("signs in through the browser, refreshes expired tokens, and signs out", async () => {
const { harness, server, notifications, backend } = await setup("follow");
await harness.session.prompt("/mcp");
// Startup problems are reported once, pointing to /mcp.
expect(notifications).toContain("MCP servers need attention:\n issues: needs sign-in\nRun /mcp to fix.");
expect(notifications.at(-1)).toBe("issues: needs sign-in, run /mcp login issues (direct)");
await harness.session.prompt("/mcp login issues");
expect(notifications.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).');
expect(server.log).toEqual(["401 none", "register", "token code"]);
expect(backend.withLock((current) => ({ result: current }))).toContain('"access_token": "access-1"');
expect(getMessageText(await callWhoami(harness))).toBe("token access-1");
// An expired access token is refreshed without user interaction.
server.expireAccessTokens();
expect(getMessageText(await callWhoami(harness))).toBe("token access-2");
expect(server.log.slice(-3)).toEqual(["401 access-1", "token refresh", "call access-2"]);
// A token past its expiry is refreshed before the request, without a 401 round trip.
backend.withLock((current) => {
const states = JSON.parse(current ?? "{}") as Record<string, { tokensExpireAt?: number }>;
for (const state of Object.values(states)) state.tokensExpireAt = Date.now() - 1_000;
return { result: undefined, next: JSON.stringify(states) };
});
expect(getMessageText(await callWhoami(harness))).toBe("token access-3");
expect(server.log.slice(-2)).toEqual(["token refresh", "call access-3"]);
await harness.session.prompt("/mcp logout issues");
expect(notifications.at(-1)).toBe('Signed out of MCP server "issues".');
const result = await callWhoami(harness);
expect(result.isError).toBe(true);
expect(getMessageText(result)).toBe('MCP server "issues" requires sign-in. Run /mcp to sign in.');
});
it("accepts a pasted redirect URL when the browser cannot reach the callback", async () => {
const { harness, server, notifications } = await setup("paste");
await harness.session.prompt("/mcp login");
expect(notifications.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).');
expect(getMessageText(await callWhoami(harness))).toBe(`token access-1`);
expect(server.log).toContain("token code");
});
async function freePort(): Promise<number> {
return new Promise<number>((resolve) => {
const probe = createServer().listen(0, "127.0.0.1", () => {
const address = probe.address() as AddressInfo;
probe.close(() => resolve(address.port));
});
});
}
it("uses the configured callback URL and scope", async () => {
const callbackUrl = `http://localhost:${await freePort()}/callback`;
const { harness, notifications, opened } = await setup("follow", { callbackUrl, scope: "issues:read" });
await harness.session.prompt("/mcp login issues");
expect(notifications.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).');
expect(opened[0].searchParams.get("redirect_uri")).toBe(callbackUrl);
expect(opened[0].searchParams.get("scope")).toBe("issues:read");
expect(getMessageText(await callWhoami(harness))).toBe("token access-1");
});
it("adds the listening port to a callback URL without one", async () => {
const { harness, notifications, opened } = await setup("follow", { callbackUrl: "http://127.0.0.1/oauth/done" });
await harness.session.prompt("/mcp login issues");
expect(notifications.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).');
expect(opened[0].searchParams.get("redirect_uri")).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/done$/);
const port = await freePort();
const fixed = await setup("follow", { callbackUrl: "http://127.0.0.1/oauth/done", callbackPort: port });
await fixed.harness.session.prompt("/mcp login issues");
expect(fixed.opened[0].searchParams.get("redirect_uri")).toBe(`http://127.0.0.1:${port}/oauth/done`);
});
it("uses credentials from pi mcp login on the next turn", async () => {
const { harness, server, backend } = await setup("follow");
const agentDir = mkdtempSync(join(tmpdir(), "pi-mcp-login-"));
cleanups.push(() => rmSync(agentDir, { recursive: true, force: true }));
writeFileSync(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: { issues: { url: server.url } } }));
// The agent runs `pi mcp login issues` through bash; the user approves in the browser.
const output: string[] = [];
const exitCode = await runMcpCommand(["login", "issues"], {
cwd: agentDir,
agentDir,
credentials: new McpOAuthCredentialStore(backend),
openUrl: (url) => void fetch(url),
log: (line) => output.push(line),
error: (line) => output.push(line),
});
expect(exitCode).toBe(0);
expect(output.at(-1)).toBe('Signed in to MCP server "issues" (1 tools).');
// The session still waits for a sign-in, and reconnects when the next turn starts.
expect(getMessageText(await callWhoami(harness))).toBe("token access-1");
});
});