mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Servers with auth.provider, such as Radius, were reported as needing sign-in because the shell command sent no token. pi mcp now reads and refreshes the provider's /login token, and its hints and login/logout point to /login <provider>.
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
|
|
import { runMcpCommand } from "../src/extensions/mcp/cli.ts";
|
|
import { startOAuthMcpServer } from "./suite/mcp-oauth-server.ts";
|
|
|
|
const FIXTURE = resolve(import.meta.dirname, "../../mcp/test/fixtures/stdio-server.mjs");
|
|
|
|
describe("pi mcp", () => {
|
|
const dirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
while (dirs.length > 0) rmSync(dirs.pop() ?? "", { recursive: true, force: true });
|
|
});
|
|
|
|
async function run(
|
|
args: string[],
|
|
servers: Record<string, unknown> | undefined,
|
|
dir?: string,
|
|
providerToken?: (provider: string) => Promise<string | undefined>,
|
|
) {
|
|
const agentDir = dir ?? mkdtempSync(join(tmpdir(), "pi-mcp-command-"));
|
|
if (!dir) dirs.push(agentDir);
|
|
if (servers) writeFileSync(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: servers }));
|
|
const output: string[] = [];
|
|
const exitCode = await runMcpCommand(args, {
|
|
cwd: agentDir,
|
|
agentDir,
|
|
providerToken,
|
|
log: (line) => output.push(line),
|
|
error: (line) => output.push(line),
|
|
});
|
|
return { exitCode, output: output.join("\n"), agentDir };
|
|
}
|
|
|
|
const readConfig = (path: string) => JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
|
|
const servers = {
|
|
fixture: { command: process.execPath, args: [FIXTURE] },
|
|
broken: { command: "pi-test-missing-mcp-server" },
|
|
parked: { command: process.execPath, args: [FIXTURE], enabled: false },
|
|
bad: { args: ["no command"] },
|
|
};
|
|
|
|
it("lists servers with their state, tools, and errors, and fails while anything is wrong", async () => {
|
|
const { exitCode, output } = await run(["list"], servers);
|
|
expect(exitCode).toBe(1);
|
|
expect(output).toContain("fixture: connected, 1 tool (codemode, global)\n");
|
|
expect(output).toContain(" tools: echo");
|
|
expect(output).toContain(
|
|
"broken: failed (codemode, global)\n pi-test-missing-mcp-server\n spawn pi-test-missing-mcp-server ENOENT",
|
|
);
|
|
expect(output).toContain("parked: disabled (codemode, global)");
|
|
expect(output).toContain("config error: ");
|
|
expect(output).toContain('server "bad" needs either "command"');
|
|
|
|
const ok = await run(["list"], { fixture: servers.fixture });
|
|
expect(ok.exitCode).toBe(0);
|
|
});
|
|
|
|
it("prints JSON for scripts", async () => {
|
|
const { exitCode, output } = await run(["list", "--json"], { fixture: servers.fixture, parked: servers.parked });
|
|
expect(exitCode).toBe(0);
|
|
const parsed = JSON.parse(output) as { servers: { name: string; state: string; tools: string[] }[] };
|
|
expect(parsed.servers.map(({ name, state, tools }) => ({ name, state, tools }))).toEqual([
|
|
{ name: "fixture", state: "connected", tools: ["echo"] },
|
|
{ name: "parked", state: "disabled", tools: [] },
|
|
]);
|
|
});
|
|
|
|
it("rejects unknown servers and servers without OAuth for login and logout", async () => {
|
|
expect(await run(["login", "nope"], servers)).toMatchObject({
|
|
exitCode: 1,
|
|
output: 'No MCP server named "nope". Configured: fixture, broken, parked.',
|
|
});
|
|
expect(await run(["logout", "fixture"], servers)).toMatchObject({
|
|
exitCode: 1,
|
|
output: 'MCP server "fixture" does not use OAuth. Only HTTP servers without an Authorization header do.',
|
|
});
|
|
expect((await run(["frobnicate"], servers)).exitCode).toBe(1);
|
|
});
|
|
|
|
it("suggests a configured server for a mistyped name", async () => {
|
|
expect((await run(["login", "fixtures"], servers)).output).toBe(
|
|
'No MCP server named "fixtures". Did you mean "fixture"? Configured: fixture, broken, parked.',
|
|
);
|
|
});
|
|
|
|
it("sends the token of the provider named by auth.provider", async () => {
|
|
const server = await startOAuthMcpServer();
|
|
try {
|
|
const radius = { radius: { url: server.url, auth: { provider: "radius" } } };
|
|
const token = server.issueAccessToken();
|
|
const requested: string[] = [];
|
|
const signedIn = await run(["list"], radius, undefined, async (provider) => {
|
|
requested.push(provider);
|
|
return token;
|
|
});
|
|
expect(signedIn).toMatchObject({ exitCode: 0, output: expect.stringContaining("radius: connected, 1 tool") });
|
|
expect(requested).toContain("radius");
|
|
|
|
// Without the provider's credentials the hint names the provider's /login, not `pi mcp login`.
|
|
const signedOut = await run(["list"], radius, undefined, async () => undefined);
|
|
expect(signedOut.exitCode).toBe(1);
|
|
expect(signedOut.output).toContain("radius: needs sign-in (codemode, global)");
|
|
expect(signedOut.output).toContain(" sign in with: /login radius in pi");
|
|
|
|
for (const command of ["login", "logout"]) {
|
|
expect(await run([command, "radius"], radius)).toMatchObject({
|
|
exitCode: 1,
|
|
output: `MCP server "radius" uses the credentials of provider "radius". Run /${command} radius in pi.`,
|
|
});
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
it("reports options as typed", async () => {
|
|
expect((await run(["list", "-l"], servers)).output).toContain("Unknown option -l.");
|
|
});
|
|
|
|
it("restricts login and logout to project servers with --local", async () => {
|
|
const { agentDir } = await run(["add", "-l", "local", "--", "node", "server.js"], servers);
|
|
new ProjectTrustStore(agentDir).set(agentDir, true);
|
|
|
|
expect(await run(["logout", "-l", "local"], undefined, agentDir)).toMatchObject({
|
|
exitCode: 1,
|
|
output: 'MCP server "local" does not use OAuth. Only HTTP servers without an Authorization header do.',
|
|
});
|
|
expect(await run(["login", "-l", "fixture"], undefined, agentDir)).toMatchObject({
|
|
exitCode: 1,
|
|
output: `MCP server "fixture" is defined in ${join(agentDir, "mcp.json")}, not in the project; omit --local.`,
|
|
});
|
|
});
|
|
|
|
it("adds stdio servers and passes options after the command through", async () => {
|
|
const added = await run(
|
|
["add", "--env", "A=1", "--env", "B=x=y", "files", "--", "npx", "-y", "server", "--root", "."],
|
|
undefined,
|
|
);
|
|
expect(added.exitCode).toBe(0);
|
|
expect(added.output).toContain('Added global MCP server "files"');
|
|
expect(readConfig(join(added.agentDir, "mcp.json"))).toEqual({
|
|
mcpServers: {
|
|
files: { command: "npx", args: ["-y", "server", "--root", "."], env: { A: "1", B: "x=y" } },
|
|
},
|
|
});
|
|
|
|
// Without `--`, options after the command belong to the command too.
|
|
const replaced = await run(["add", "files", "node", "server.js", "--port", "1"], undefined, added.agentDir);
|
|
expect(replaced.output).toContain('Replaced global MCP server "files"');
|
|
expect(readConfig(join(added.agentDir, "mcp.json"))).toEqual({
|
|
mcpServers: { files: { command: "node", args: ["server.js", "--port", "1"] } },
|
|
});
|
|
});
|
|
|
|
it("adds HTTP servers and keeps other content of the file", async () => {
|
|
const { exitCode, output, agentDir } = await run(
|
|
[
|
|
"add",
|
|
"docs",
|
|
"--url",
|
|
"https://example.com/mcp",
|
|
"--bearer-token-env-var",
|
|
"DOCS_TOKEN",
|
|
"--header",
|
|
"X-Team=core",
|
|
"--exposure",
|
|
"direct",
|
|
"--description",
|
|
"Product docs",
|
|
],
|
|
{ fixture: servers.fixture },
|
|
);
|
|
expect(exitCode).toBe(0);
|
|
expect(output).not.toContain("mcp login");
|
|
expect(readConfig(join(agentDir, "mcp.json"))).toEqual({
|
|
mcpServers: {
|
|
fixture: servers.fixture,
|
|
docs: {
|
|
url: "https://example.com/mcp",
|
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal config value reference
|
|
headers: { "X-Team": "core", Authorization: "Bearer ${DOCS_TOKEN}" },
|
|
exposure: "direct",
|
|
description: "Product docs",
|
|
},
|
|
},
|
|
});
|
|
|
|
const oauth = await run(
|
|
[
|
|
"add",
|
|
"sentry",
|
|
"--url",
|
|
"https://mcp.sentry.dev/mcp",
|
|
"--oauth-client-id",
|
|
"pi",
|
|
"--oauth-client-name",
|
|
"Claude Code",
|
|
],
|
|
undefined,
|
|
agentDir,
|
|
);
|
|
expect(oauth.output).toContain("If it requires sign-in: pi mcp login sentry");
|
|
expect(readConfig(join(agentDir, "mcp.json")).mcpServers).toMatchObject({
|
|
sentry: { url: "https://mcp.sentry.dev/mcp", oauth: { clientId: "pi", clientName: "Claude Code" } },
|
|
});
|
|
|
|
await run(
|
|
["add", "linear", "--url", "https://mcp.linear.app/mcp", "--oauth-client-registration", "dcr"],
|
|
undefined,
|
|
agentDir,
|
|
);
|
|
expect(readConfig(join(agentDir, "mcp.json")).mcpServers).toMatchObject({
|
|
linear: { url: "https://mcp.linear.app/mcp", oauth: { clientRegistration: "dcr" } },
|
|
});
|
|
});
|
|
|
|
it("rejects invalid add invocations without writing", async () => {
|
|
const cases = [
|
|
["add", "x"],
|
|
["add", "x", "--url", "https://example.com", "--", "cmd"],
|
|
["add", "bad name", "--", "cmd"],
|
|
["add", "x", "--url", "ftp://example.com"],
|
|
["add", "x", "--env", "A=1", "--url", "https://example.com"],
|
|
["add", "x", "--header", "A=1", "--", "cmd"],
|
|
["add", "x", "--env", "NOVALUE", "--", "cmd"],
|
|
["add", "x", "--exposure", "loud", "--", "cmd"],
|
|
["add", "x", "--url", "https://example.com", "--oauth-client-registration", "maybe"],
|
|
];
|
|
for (const args of cases) {
|
|
const result = await run(args, undefined);
|
|
expect(result.exitCode, args.join(" ")).toBe(1);
|
|
expect(existsSync(join(result.agentDir, "mcp.json"))).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("adds and removes project servers", async () => {
|
|
const added = await run(["add", "-l", "local", "--", "node", "server.js"], undefined);
|
|
expect(added.output).toContain("The project is not trusted");
|
|
const projectConfig = join(added.agentDir, ".pi", "mcp.json");
|
|
expect(readConfig(projectConfig)).toEqual({ mcpServers: { local: { command: "node", args: ["server.js"] } } });
|
|
|
|
const wrongScope = await run(["remove", "local"], undefined, added.agentDir);
|
|
expect(wrongScope.exitCode).toBe(1);
|
|
expect(wrongScope.output).toContain(`It is defined in ${projectConfig}; use --local.`);
|
|
|
|
const removed = await run(["remove", "local", "--local"], undefined, added.agentDir);
|
|
expect(removed.exitCode).toBe(0);
|
|
expect(removed.output).toContain('Removed project MCP server "local"');
|
|
expect(readConfig(projectConfig)).toEqual({ mcpServers: {} });
|
|
});
|
|
|
|
it("removes global servers", async () => {
|
|
const { exitCode, agentDir } = await run(["remove", "broken"], servers);
|
|
expect(exitCode).toBe(0);
|
|
expect(Object.keys(readConfig(join(agentDir, "mcp.json")).mcpServers as object)).toEqual([
|
|
"fixture",
|
|
"parked",
|
|
"bad",
|
|
]);
|
|
const missing = await run(["remove", "broken"], undefined, agentDir);
|
|
expect(missing).toMatchObject({ exitCode: 1 });
|
|
expect(missing.output).toContain('No global MCP server named "broken"');
|
|
});
|
|
});
|