diff --git a/.scratch/mcp-extension-ui/HANDOFF.md b/.scratch/mcp-extension-ui/HANDOFF.md index a77d34af..27e30352 100644 --- a/.scratch/mcp-extension-ui/HANDOFF.md +++ b/.scratch/mcp-extension-ui/HANDOFF.md @@ -1,7 +1,7 @@ # Handoff:MCP 与扩展交互在 Pace 中的呈现 - 日期:2026-09-30 -- 状态:待讨论。本文是交接材料,不是决策记录。 +- 状态:已决(2026-10-01)。不做 `ctx.ui` 桥,MCP 管理放进设置页,见 [mcp-settings/PRD.md](../mcp-settings/PRD.md);会话内 notify 暂缓,见 #416。下文保留为当时的交接材料。§3.3 “不能依赖 `pi` CLI” 已被推翻:`pi mcp` 的核心函数 `runMcpCommand` 可以打包进 Pace。 - 关联:PR #412(Pi 0.99.1,加载内置 codemode / tool_search / MCP,[ADR-0046](../../docs/adr/0046-load-pi-builtin-extensions.md))、PR #414(Nested Tool Execution 呈现)、[ADR-0018](../../docs/adr/0018-runtime-gateway-api-and-pi-drivers.md)、[ADR-0031](../../docs/adr/0031-bundled-pi-runtime-and-extension-compatibility.md)、[ADR-0040](../../docs/adr/0040-root-session-process-isolation.md)、[ADR-0042](../../docs/adr/0042-pace-as-chord-presentation-host.md)(草案)、[extension-host-contract/HANDOFF.md](../extension-host-contract/HANDOFF.md) - 目的:Pi 0.99 适配的前两步已经完成,第三步有设计取舍。本文把已查明的事实和待决问题交给新会话,避免重查。 diff --git a/.scratch/mcp-settings/PRD.md b/.scratch/mcp-settings/PRD.md new file mode 100644 index 00000000..dba66a47 --- /dev/null +++ b/.scratch/mcp-settings/PRD.md @@ -0,0 +1,52 @@ +# PRD:设置页 MCP 管理 + +- 日期:2026-10-01 +- 来源:[mcp-extension-ui/HANDOFF.md](../mcp-extension-ui/HANDOFF.md) 的第三阶段讨论 +- 关联:PR #412(加载内置 MCP 扩展,ADR-0046)、#416(会话内 notify 提醒,暂缓) + +## 问题 + +Pace 已经能加载 Pi 内置的 MCP 扩展、连接 `mcp.json` 里的服务器,但用户在 Pace 里看不到服务器状态,也无法登录 OAuth 服务器、启用/禁用服务器或修改 exposure。内置扩展把这些能力都放在 TUI 的 `/mcp` 管理器里(`ctx.ui.custom`),状态封在扩展闭包里,宿主拿不到。 + +## 决策 + +1. **MCP 管理放进设置页,UI 由 Pace 自己写**,和内置终端、浏览器一样当作 Pace 的一等功能。不把 `ctx.ui` 映射到 GUI,也不托管 TUI 组件。 +2. **三方扩展的 UI 不在本范围**,等 Chord(ADR-0042)落地后统一解决。三方 MCP 扩展顶替内置扩展的情况暂不处理。 +3. **数据和操作复用 Pi 包内的 `pi mcp` 实现**,不自己写 MCP 客户端: + - `dist/extensions/mcp/cli.js` 的 `runMcpCommand(args, { cwd, agentDir, openUrl, log, error })`:`list --json` 探测状态,`login` / `logout` / `add` / `remove` 做操作。 + - `dist/extensions/mcp/config.js` 的 `loadMcpConfig`(即时读配置)和 `updateMcpServerConfig`(启用/禁用、exposure)。 + - 这两个文件不在包的 `exports` 里,按文件路径打包。这是与固定 Pi 版本的耦合,靠契约测试兜底:Pi 升级后结构一变,测试失败。 +4. **会话内提醒暂缓**,见 #416。 + +## 范围(第一版) + +- 设置页新增 “MCP” 分区。 +- 打开分区时先即时显示配置里的服务器(来自 `loadMcpConfig`),再异步探测状态(`list --json`,最长可能等到单个服务器的超时,默认 60 秒)。另提供手动刷新,不轮询。 +- 每个服务器显示:名称、传输方式(命令或 URL)、状态(connected / needs-auth / failed / disabled / 检测中)、错误信息、工具列表、exposure。 +- 操作: + - 登录 / 登出:只对使用 OAuth 的服务器(HTTP 且没有 `Authorization` 头)显示。登录通过浏览器授权,超时 120 秒,期间显示等待状态,不支持粘贴回调 URL,不支持中途取消。 + - 启用 / 禁用、修改 exposure:写回服务器所在的 `mcp.json`。 + - 添加服务器:名称 + stdio(命令、每行一个参数)或 HTTP(URL)+ exposure。env、headers、OAuth 客户端等高级选项仍需编辑 `mcp.json`。 + - 删除服务器:需确认。 +- 显示配置错误(`mcp.json` 解析失败等)和全局 `mcp.json` 路径。 +- 只管全局 `mcp.json`(agentDir 下)。项目级 `.pi/mcp.json` 与项目信任放到后续。 + +## 生效规则 + +- 会话只在启动时读取 MCP 配置,所以添加、删除、启用/禁用、exposure 的改动**只对新会话生效**。界面上明确写出。 +- 登录例外:凭据写入 `mcp-auth.json` 后,运行中的会话会在下一轮开始时自动重连。 + +## 已知限制 + +- 设置页的状态是 backend 自己连接一次得到的**探测结果**,不是某个会话里的实时连接状态。每次探测都会把每个启用的 stdio 服务器启动一次再关掉。 +- 每个 Session 进程仍各自连接一遍所有服务器(ADR-0040 的现状),本 PRD 不改变。 + +## 等上游 + +- MCP 扩展公开状态(例如 `createMcpExtension({ onStateChange })` 或在 `pi.events` 上发布)。有了以后,会话内的实时状态可以经 Session 进程协议上报,设置页的探测只作兜底。 +- `runMcpCommand` / `config.js` 进入包的 `exports`,去掉按路径打包的耦合。 + +## 切片 + +1. Backend:MCP 服务(配置读取、探测、登录登出、启用禁用、exposure、添加删除)+ RPC 方法 + 打包别名 + 契约测试。 +2. 设置页 “MCP” 分区 + 浏览器 dev fallback / mock 数据。 diff --git a/README.md b/README.md index 6d3400a0..7982ad3d 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ flowchart TB | Persistence and replay | [`packages/backend/src/persistence/`](packages/backend/src/persistence/) | | Packages page (sidebar): packages, resources, update checks and journal diagnostics | [`apps/desktop/src/pages/setup.tsx`](apps/desktop/src/pages/setup.tsx), [`packages/backend/src/workspace/resource-management.ts`](packages/backend/src/workspace/resource-management.ts), [`resource-diagnostics.ts`](packages/backend/src/workspace/resource-diagnostics.ts) ([ADR-0037](docs/adr/0037-resource-management.md)) | | Model availability (catalog, capability mapping, credential-driven refresh) | [`packages/backend/src/workspace/model-catalog.ts`](packages/backend/src/workspace/model-catalog.ts), mapping in [`packages/core/src/model-capabilities.ts`](packages/core/src/model-capabilities.ts) ([ADR-0043](docs/adr/0043-model-catalog-owns-model-availability.md)) | +| MCP server management (global `mcp.json`, probes, OAuth sign-in) | [`packages/backend/src/workspace/mcp-servers.ts`](packages/backend/src/workspace/mcp-servers.ts), contracts in [`packages/core/src/mcp-servers.ts`](packages/core/src/mcp-servers.ts) | | Sessions on disk, git worktrees, config inventory | [`packages/backend/src/workspace/`](packages/backend/src/workspace/) | | Electron shell and transport | [`apps/desktop/electron/`](apps/desktop/electron/): `main.ts`, `preload.ts`, `backend.ts` | | Dock surfaces (Changes, Files, Terminal, Browser) | [`apps/desktop/src/shared/ui/session-dock/surface-registry.ts`](apps/desktop/src/shared/ui/session-dock/surface-registry.ts) | diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 23629790..c48308f2 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -209,6 +209,9 @@ const rendererBuild = { const coreAlias = { "@pace/core": resolve(__dirname, "../../packages/core/src/index.ts"), "@pace/backend": resolve(__dirname, "../../packages/backend/src/index.ts"), + // Not in Pi's package exports map, so bundled by file path like the codemode + // worker entry above; the alias pins the backend to the installed Pi version. + "@pace/pi-mcp": join(piPackageDirectory, "dist/extensions/mcp"), "@": resolve(__dirname, "src"), }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1e91e7be..bd1eb4b5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -25,6 +25,12 @@ export { type ProviderAuthServiceOptions, } from "./workspace/provider-auth"; +export { + createMcpServersService, + type McpServersService, + type McpServersServiceOptions, +} from "./workspace/mcp-servers"; + export { createRuntimeGatewayService, type RuntimeGatewayDriverEvent, diff --git a/packages/backend/src/service.test.ts b/packages/backend/src/service.test.ts index af8b56b8..b1caa753 100644 --- a/packages/backend/src/service.test.ts +++ b/packages/backend/src/service.test.ts @@ -335,6 +335,42 @@ describe("backend service", () => { expect(modelCatalog.refresh).toHaveBeenCalledWith({ allowNetwork: true, force: true }); }); + it("routes MCP server commands to the MCP servers service", async () => { + const report = { + configPath: "/tmp/agent/mcp.json", + servers: [], + errors: [], + }; + const mcpServers = { + getConfig: vi.fn(async () => report), + probe: vi.fn(async () => ({ servers: [], errors: [] })), + login: vi.fn(async () => ({ ok: true, message: "" })), + logout: vi.fn(async () => ({ ok: true, message: "" })), + setEnabled: vi.fn(async () => report), + setExposure: vi.fn(async () => report), + add: vi.fn(async () => ({ ok: true, message: "" })), + remove: vi.fn(async () => ({ ok: true, message: "" })), + }; + const service = createBackendService({ + agentDir: fixtureAgentDir(), + mcpServers, + runtimeJournal: createInMemorySessionEventJournal(), + sessionProjectionStore: createInMemorySessionProjectionStore(), + }); + + await expect( + service.handleRequest({ id: "config", method: "get_mcp_config" }), + ).resolves.toEqual({ id: "config", result: report }); + await expect( + service.handleRequest({ + id: "exposure", + method: "set_mcp_server_exposure", + params: { name: "docs", exposure: "direct" }, + }), + ).resolves.toEqual({ id: "exposure", result: report }); + expect(mcpServers.setExposure).toHaveBeenCalledWith("docs", "direct"); + }); + it("routes tool schema resolution through the Runtime Gateway", async () => { const bashSchema = { description: "Execute a shell command", diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index 0b9a02e1..aec15b28 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -6,6 +6,8 @@ import { stat } from "node:fs/promises"; import { homedir } from "node:os"; import type { ExecutionCheckoutGitClient, + McpAddServerInput, + McpExposure, ModelCatalogInvalidatedPayload, ProviderAuthId, PromptCommandCatalog, @@ -34,6 +36,10 @@ import { createProviderAuthService, type ProviderAuthService, } from "./workspace/provider-auth"; +import { + createMcpServersService, + type McpServersService, +} from "./workspace/mcp-servers"; import { createModelCatalog, type ModelCatalog } from "./workspace/model-catalog"; import { readSettingsPreferredModel } from "./workspace/pi-settings"; import { createNodeExecutionCheckoutGitClient } from "./workspace/execution-checkout"; @@ -140,6 +146,7 @@ export type BackendServiceOptions = { piSessionListAll?: () => Promise; environmentPreflight?: EnvironmentPreflightReader; providerAuth?: ProviderAuthService; + mcpServers?: McpServersService; modelCatalog?: ModelCatalog; terminalManager?: TerminalManager; /** Fetch the signed-in accounts' model lists once at startup (the app sets this; tests do not). */ @@ -235,6 +242,7 @@ export function createBackendService(options: BackendServiceOptions = {}): Backe agentDir, runtime: getModelRuntime, }); + const mcpServers = options.mcpServers ?? createMcpServersService({ agentDir }); const piSessionListAll = options.piSessionListAll ?? (async () => { @@ -375,6 +383,7 @@ export function createBackendService(options: BackendServiceOptions = {}): Backe staticPromptCommands, environmentPreflight, providerAuth, + mcpServers, piSessionListAll, runtimeGateway, runtimeDriver, @@ -416,6 +425,7 @@ async function dispatchRequest(input: { staticPromptCommands: StaticPromptCommandsResolver; environmentPreflight: EnvironmentPreflightReader; providerAuth: ProviderAuthService; + mcpServers: McpServersService; piSessionListAll: () => Promise; runtimeGateway: RuntimeGatewayService; runtimeDriver: PiRuntimeDriver; @@ -601,6 +611,28 @@ async function dispatchRequest(input: { requiredString(params.providerId, "providerId") as ProviderAuthId, optionalString(params.modelId), ); + case "get_mcp_config": + return input.mcpServers.getConfig(); + case "probe_mcp_servers": + return input.mcpServers.probe(); + case "login_mcp_server": + return input.mcpServers.login(requiredString(params.name, "name")); + case "logout_mcp_server": + return input.mcpServers.logout(requiredString(params.name, "name")); + case "set_mcp_server_enabled": + return input.mcpServers.setEnabled( + requiredString(params.name, "name"), + requiredBoolean(params.enabled, "enabled"), + ); + case "set_mcp_server_exposure": + return input.mcpServers.setExposure( + requiredString(params.name, "name"), + requiredMcpExposure(params.exposure), + ); + case "add_mcp_server": + return input.mcpServers.add(mcpAddServerInput(params.input)); + case "remove_mcp_server": + return input.mcpServers.remove(requiredString(params.name, "name")); case "list_available_model_controls": return input.modelCatalog.list(); case "refresh_model_catalog": @@ -1032,6 +1064,61 @@ function requiredString(value: unknown, name: string) { return value; } +function requiredBoolean(value: unknown, name: string) { + if (typeof value !== "boolean") { + throw new Error(`${name} must be a boolean`); + } + + return value; +} + +const MCP_EXPOSURES: readonly McpExposure[] = [ + "codemode", + "codemode-deferred", + "deferred", + "direct", + "hidden", +]; + +function requiredMcpExposure(value: unknown): McpExposure { + if (typeof value !== "string" || !MCP_EXPOSURES.includes(value as McpExposure)) { + throw new Error(`exposure must be one of ${MCP_EXPOSURES.map((entry) => `"${entry}"`).join(", ")}`); + } + + return value as McpExposure; +} + +function mcpAddServerInput(value: unknown): McpAddServerInput { + const input = requiredRecord(value, "input"); + const name = requiredString(input.name, "input.name"); + const exposure = + input.exposure === undefined ? undefined : requiredMcpExposure(input.exposure); + + if (input.kind === "http") { + return { + kind: "http", + name, + url: requiredString(input.url, "input.url"), + ...(exposure ? { exposure } : {}), + }; + } + + if (input.kind === "stdio") { + return { + kind: "stdio", + name, + command: requiredString(input.command, "input.command"), + args: + input.args === undefined + ? [] + : requiredStringArray(input.args, "input.args"), + ...(exposure ? { exposure } : {}), + }; + } + + throw new Error('input.kind must be "stdio" or "http"'); +} + function optionalString(value: unknown) { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/packages/backend/src/workspace/fixtures/mcp-test-server.d.mts b/packages/backend/src/workspace/fixtures/mcp-test-server.d.mts new file mode 100644 index 00000000..d149923b --- /dev/null +++ b/packages/backend/src/workspace/fixtures/mcp-test-server.d.mts @@ -0,0 +1,13 @@ +// Types for the dependency-free MCP test server fixture. The handler is the +// counterpart under test, so the HTTP variant imports it rather than copying +// the logic. + +export declare function handleMcpMessage(message: { + id?: string | number | null; + method?: string; + params?: { + protocolVersion?: string; + name?: string; + arguments?: Record; + }; +}): unknown; diff --git a/packages/backend/src/workspace/fixtures/mcp-test-server.mjs b/packages/backend/src/workspace/fixtures/mcp-test-server.mjs new file mode 100644 index 00000000..476f6daa --- /dev/null +++ b/packages/backend/src/workspace/fixtures/mcp-test-server.mjs @@ -0,0 +1,122 @@ +// Dependency-free MCP test server for the mcp-servers contract tests. +// `handleMcpMessage` is shared by the stdio transport below and the in-process +// HTTP server the tests spin up, so both transports exercise the same handler. + +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + +const TOOLS = [ + { + name: "echo", + description: "Echo the text back.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + { + name: "add", + description: "Add two numbers.", + inputSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "number" } }, + required: ["a", "b"], + }, + }, +]; + +function reply(id, result) { + return { jsonrpc: "2.0", id, result }; +} + +function replyError(id, code, message) { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +/** + * Answer one JSON-RPC request. Notifications (no id) return undefined — the + * transport must not send a response for them. + */ +export function handleMcpMessage(message) { + if (message === null || typeof message !== "object" || Array.isArray(message)) { + return replyError(null, -32600, "Invalid Request"); + } + if (message.id === undefined || message.id === null) { + return undefined; + } + const { id, method, params } = message; + switch (method) { + case "initialize": { + // The client rejects a protocolVersion it does not support; echoing the + // requested version always lands inside its supported list. + const requested = params?.protocolVersion; + return reply(id, { + protocolVersion: PROTOCOL_VERSIONS.includes(requested) + ? requested + : PROTOCOL_VERSIONS[0], + capabilities: { tools: {} }, + serverInfo: { name: "pace-mcp-test", version: "1.0.0" }, + }); + } + case "ping": + return reply(id, {}); + case "tools/list": + return reply(id, { tools: TOOLS }); + case "tools/call": { + const { name, arguments: args } = params ?? {}; + if (name === "echo") { + return reply(id, { + content: [{ type: "text", text: String(args?.text ?? "") }], + }); + } + if (name === "add") { + return reply(id, { + content: [ + { type: "text", text: String(Number(args?.a) + Number(args?.b)) }, + ], + }); + } + return reply(id, { + isError: true, + content: [{ type: "text", text: `Unknown tool "${name}".` }], + }); + } + default: + return replyError(id, -32601, `Unknown method "${method}".`); + } +} + +// Pi's StdioTransport frames messages as newline-delimited JSON. +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + let pending = ""; + process.stdin.on("data", (chunk) => { + pending += chunk; + for (;;) { + const newline = pending.indexOf("\n"); + if (newline < 0) break; + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (!line.trim()) continue; + let message; + try { + message = JSON.parse(line); + } catch { + process.stdout.write( + `${JSON.stringify(replyError(null, -32700, "Parse error"))}\n`, + ); + continue; + } + const response = handleMcpMessage(message); + if (response !== undefined) { + process.stdout.write(`${JSON.stringify(response)}\n`); + } + } + }); +} diff --git a/packages/backend/src/workspace/mcp-servers.test.ts b/packages/backend/src/workspace/mcp-servers.test.ts new file mode 100644 index 00000000..5c7db0a7 --- /dev/null +++ b/packages/backend/src/workspace/mcp-servers.test.ts @@ -0,0 +1,369 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { McpServerConfigItem } from "@pace/core"; +import { createMcpServersService } from "./mcp-servers"; +import { handleMcpMessage } from "./fixtures/mcp-test-server.mjs"; + +// Contract test against the real @earendil-works/pi-coding-agent package: it +// must break when a Pi upgrade changes the mcp.json shape, the `pi mcp` +// argument syntax, or the `list --json` report the service parses. + +const tempDirs: string[] = []; + +let agentDir: string; + +async function writeMcpJson(dir: string, value: unknown) { + await writeFile(join(dir, "mcp.json"), JSON.stringify(value, null, 2)); +} + +async function readMcpJson(dir: string) { + return JSON.parse(await readFile(join(dir, "mcp.json"), "utf8")); +} + +function serversByName(servers: McpServerConfigItem[]) { + return new Map(servers.map((server) => [server.name, server])); +} + +describe("mcp-servers service", () => { + beforeEach(async () => { + agentDir = await mkdtemp(join(tmpdir(), "pigui-mcp-")); + tempDirs.push(agentDir); + // runMcpCommand resolves mcp-auth.json through Pi's getAgentDir(), which + // reads PI_CODING_AGENT_DIR at call time; without the stub the default + // credential store would read the real ~/.pi/agent. + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("reports the global mcp.json servers", async () => { + await writeMcpJson(agentDir, { + mcpServers: { + "stdio-one": { command: "node", args: ["server.js", "--flag"] }, + "http-oauth": { url: "https://example.com/mcp" }, + "http-keyed": { + url: "https://example.com/keyed", + headers: { AUTHORIZATION: "Bearer token" }, + }, + "disabled-one": { command: "node", enabled: false }, + "direct-one": { command: "node", exposure: "direct" }, + }, + }); + + const report = await createMcpServersService({ agentDir }).getConfig(); + + expect(report.configPath).toBe(join(agentDir, "mcp.json")); + expect(report.errors).toEqual([]); + const servers = serversByName(report.servers); + expect([...servers.keys()].sort()).toEqual([ + "direct-one", + "disabled-one", + "http-keyed", + "http-oauth", + "stdio-one", + ]); + expect(servers.get("stdio-one")).toMatchObject({ + kind: "stdio", + transport: "node server.js --flag", + usesOAuth: false, + enabled: true, + exposure: "codemode", + }); + expect(servers.get("http-oauth")).toMatchObject({ + kind: "http", + transport: "https://example.com/mcp", + usesOAuth: true, + }); + // An Authorization header under any casing opts out of OAuth. + expect(servers.get("http-keyed")?.usesOAuth).toBe(false); + expect(servers.get("disabled-one")?.enabled).toBe(false); + expect(servers.get("direct-one")?.exposure).toBe("direct"); + }); + + it("reports mcp.json parse errors without servers", async () => { + await writeFile(join(agentDir, "mcp.json"), "{ not json"); + + const report = await createMcpServersService({ agentDir }).getConfig(); + + expect(report.servers).toEqual([]); + expect(report.errors.length).toBeGreaterThan(0); + }); + + it("reports an empty config when mcp.json does not exist", async () => { + const report = await createMcpServersService({ agentDir }).getConfig(); + + expect(report).toEqual({ + configPath: join(agentDir, "mcp.json"), + servers: [], + errors: [], + }); + }); + + it("probes server states through pi mcp list --json", async () => { + await writeMcpJson(agentDir, { + mcpServers: { + off: { command: "pigui-no-such-command", enabled: false }, + broken: { command: "pigui-no-such-command", timeout: 5 }, + }, + }); + + const report = await createMcpServersService({ agentDir }).probe(); + + expect(report.errors).toEqual([]); + const servers = new Map(report.servers.map((server) => [server.name, server])); + expect(servers.get("off")).toMatchObject({ state: "disabled", tools: [] }); + const broken = servers.get("broken"); + expect(broken?.state).toBe("failed"); + expect(broken?.error).toBeTruthy(); + }); + + it("probes an empty report when no servers are configured", async () => { + const report = await createMcpServersService({ agentDir }).probe(); + + expect(report).toEqual({ servers: [], errors: [] }); + }); + + it("writes enabled and exposure changes back to mcp.json", async () => { + await writeMcpJson(agentDir, { + mcpServers: { srv: { command: "node", enabled: false } }, + }); + const service = createMcpServersService({ agentDir }); + + const enabledReport = await service.setEnabled("srv", true); + expect(serversByName(enabledReport.servers).get("srv")?.enabled).toBe(true); + // `enabled: true` is the default, so Pi removes the key. + expect((await readMcpJson(agentDir)).mcpServers.srv.enabled).toBeUndefined(); + + const disabledReport = await service.setEnabled("srv", false); + expect(serversByName(disabledReport.servers).get("srv")?.enabled).toBe(false); + expect((await readMcpJson(agentDir)).mcpServers.srv.enabled).toBe(false); + + const exposureReport = await service.setExposure("srv", "direct"); + expect(serversByName(exposureReport.servers).get("srv")?.exposure).toBe("direct"); + expect((await readMcpJson(agentDir)).mcpServers.srv.exposure).toBe("direct"); + + // `codemode` is the default exposure, so Pi removes the key. + const codemodeReport = await service.setExposure("srv", "codemode"); + expect(serversByName(codemodeReport.servers).get("srv")?.exposure).toBe("codemode"); + expect((await readMcpJson(agentDir)).mcpServers.srv.exposure).toBeUndefined(); + }); + + it("adds and removes servers in mcp.json", async () => { + const service = createMcpServersService({ agentDir }); + + const addedStdio = await service.add({ + kind: "stdio", + name: "echo", + command: "node", + args: ["echo.js"], + }); + expect(addedStdio.ok).toBe(true); + const addedHttp = await service.add({ + kind: "http", + name: "docs", + url: "https://example.com/mcp", + exposure: "direct", + }); + expect(addedHttp.ok).toBe(true); + + const file = await readMcpJson(agentDir); + expect(file.mcpServers.echo).toMatchObject({ + command: "node", + args: ["echo.js"], + }); + expect(file.mcpServers.docs).toMatchObject({ + url: "https://example.com/mcp", + exposure: "direct", + }); + + const removed = await service.remove("echo"); + expect(removed.ok).toBe(true); + expect((await readMcpJson(agentDir)).mcpServers.echo).toBeUndefined(); + + const missing = await service.remove("echo"); + expect(missing.ok).toBe(false); + expect(missing.message).toContain("echo"); + }); + + it("rejects invalid add input without touching mcp.json", async () => { + await writeMcpJson(agentDir, { mcpServers: { keep: { command: "node" } } }); + const before = await readFile(join(agentDir, "mcp.json"), "utf8"); + const service = createMcpServersService({ agentDir }); + + await expect( + service.add({ kind: "stdio", name: "", command: "node", args: [] }), + ).rejects.toThrow(); + await expect( + service.add({ kind: "stdio", name: "x", command: " ", args: [] }), + ).rejects.toThrow(); + await expect( + service.add({ kind: "http", name: "x", url: "" }), + ).rejects.toThrow(); + + expect(await readFile(join(agentDir, "mcp.json"), "utf8")).toBe(before); + }); + + it("probes a connected stdio server through a real MCP handshake", async () => { + await writeMcpJson(agentDir, { + mcpServers: { + "test-stdio": { + command: process.execPath, + args: [join(import.meta.dirname, "fixtures/mcp-test-server.mjs")], + }, + }, + }); + + const report = await createMcpServersService({ agentDir }).probe(); + + expect(report.errors).toEqual([]); + const server = report.servers.find((entry) => entry.name === "test-stdio"); + expect(server?.state).toBe("connected"); + expect(server?.tools).toEqual(expect.arrayContaining(["echo", "add"])); + expect(server?.error).toBeUndefined(); + }); + + it("probes a connected HTTP server through a real MCP handshake", async () => { + const httpServer: Server = createServer(async (request, response) => { + if (request.method !== "POST") { + response.writeHead(405).end(); + return; + } + let body = ""; + for await (const chunk of request) { + body += chunk; + } + const reply = handleMcpMessage(JSON.parse(body)); + // Notifications and client responses are acknowledged with 202. + if (reply === undefined) { + response.writeHead(202).end(); + return; + } + response + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify(reply)); + }); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + try { + const { port } = httpServer.address() as AddressInfo; + await writeMcpJson(agentDir, { + mcpServers: { + "test-http": { url: `http://127.0.0.1:${port}/mcp` }, + }, + }); + + const report = await createMcpServersService({ agentDir }).probe(); + + expect(report.errors).toEqual([]); + const server = report.servers.find((entry) => entry.name === "test-http"); + expect(server?.state).toBe("connected"); + expect(server?.tools).toEqual(expect.arrayContaining(["echo", "add"])); + expect(server?.error).toBeUndefined(); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } + }); + + it("reports needs-auth for an HTTP server that answers 401", async () => { + const httpServer: Server = createServer((request, response) => { + response + .writeHead(401, { "www-authenticate": "Bearer" }) + .end(); + }); + await new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", resolve); + }); + try { + const { port } = httpServer.address() as AddressInfo; + await writeMcpJson(agentDir, { + mcpServers: { + "test-oauth": { url: `http://127.0.0.1:${port}/mcp` }, + }, + }); + const service = createMcpServersService({ agentDir }); + + // HTTP without an Authorization header is the OAuth shape. + const config = await service.getConfig(); + expect( + config.servers.find((entry) => entry.name === "test-oauth")?.usesOAuth, + ).toBe(true); + + const report = await service.probe(); + const server = report.servers.find( + (entry) => entry.name === "test-oauth", + ); + expect(server?.state).toBe("needs-auth"); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } + }); + + it("reports whether an OAuth server has stored credentials", async () => { + const port = 8999; + await writeMcpJson(agentDir, { + mcpServers: { + "signed-in": { url: `http://127.0.0.1:${port}/mcp` }, + unsigned: { url: `http://127.0.0.1:${port}/other` }, + }, + }); + // Pi keys mcp-auth.json by the normalized server URL string. + await writeFile( + join(agentDir, "mcp-auth.json"), + JSON.stringify({ + [`http://127.0.0.1:${port}/mcp`]: { + tokens: { access_token: "t", token_type: "Bearer" }, + }, + }), + ); + const service = createMcpServersService({ agentDir }); + + const before = await service.getConfig(); + expect( + before.servers.find((entry) => entry.name === "signed-in") + ?.hasStoredCredentials, + ).toBe(true); + expect( + before.servers.find((entry) => entry.name === "unsigned") + ?.hasStoredCredentials, + ).toBe(false); + + const logout = await service.logout("signed-in"); + expect(logout.ok).toBe(true); + const after = await service.getConfig(); + expect( + after.servers.find((entry) => entry.name === "signed-in") + ?.hasStoredCredentials, + ).toBe(false); + }); + + it("reports no stored credentials for stdio servers", async () => { + await writeMcpJson(agentDir, { + mcpServers: { + "stdio-only": { command: "any-command" }, + }, + }); + const config = await createMcpServersService({ agentDir }).getConfig(); + expect(config.servers[0]?.hasStoredCredentials).toBe(false); + }); + + it("fails logout for a server that does not use OAuth", async () => { + await writeMcpJson(agentDir, { + mcpServers: { stdio: { command: "node" } }, + }); + + const result = await createMcpServersService({ agentDir }).logout("stdio"); + + expect(result.ok).toBe(false); + expect(result.message).toContain("does not use OAuth"); + }); +}); diff --git a/packages/backend/src/workspace/mcp-servers.ts b/packages/backend/src/workspace/mcp-servers.ts new file mode 100644 index 00000000..cfb0c8f9 --- /dev/null +++ b/packages/backend/src/workspace/mcp-servers.ts @@ -0,0 +1,262 @@ +// MCP server management for Settings: reads the global mcp.json, probes +// connection state, and performs add/remove/login/logout through Pi's own +// `pi mcp` implementation instead of a Pace-written MCP client, so transport +// choice, the OAuth rule, and the credential store always match the sessions. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { runMcpCommand } from "@pace/pi-mcp/cli.js"; +import { + loadMcpConfig, + updateMcpServerConfig, + type McpServerEntry, +} from "@pace/pi-mcp/config.js"; +import { McpOAuthCredentialStore } from "@pace/pi-mcp/oauth.js"; +import type { McpExposure as PiMcpExposure } from "@earendil-works/pi-coding-agent"; +import type { + McpActionResult, + McpAddServerInput, + McpConfigReport, + McpExposure, + McpProbeReport, + McpServerConfigItem, + McpServerProbe, + McpServerState, +} from "@pace/core"; +import { openUrlInBrowser } from "./open-url"; + +// @pace/core re-declares Pi's exposure union so the renderer never imports the +// engine. Both directions are asserted: a Pi release that widens or narrows it +// fails typecheck here instead of silently drifting. +type McpExposureMatchesPi = [McpExposure] extends [PiMcpExposure] + ? [PiMcpExposure] extends [McpExposure] + ? true + : never + : never; +const mcpExposureMatchesPi: McpExposureMatchesPi = true; +void mcpExposureMatchesPi; + +export type McpServersService = { + getConfig(): Promise; + probe(): Promise; + login(name: string): Promise; + logout(name: string): Promise; + setEnabled(name: string, enabled: boolean): Promise; + setExposure(name: string, exposure: McpExposure): Promise; + add(input: McpAddServerInput): Promise; + remove(name: string): Promise; +}; + +export type McpServersServiceOptions = { + agentDir: string; + /** Opens the OAuth authorization URL; defaults to the platform browser. */ + openExternalUrl?: (url: string) => void | Promise; +}; + +/** Shape of the `pi mcp list --json` payload (extensions/mcp/cli.js `list()`). */ +type PiMcpListOutput = { + servers?: Array<{ + name: string; + scope?: string; + source?: string; + enabled?: boolean; + exposure?: McpExposure; + transport?: string; + state?: McpServerState; + tools?: string[]; + toolExposure?: Record; + resources?: number; + resourceTemplates?: number; + error?: string; + }>; + errors?: string[]; + note?: string; +}; + +type CapturedRun = { + exitCode: number; + out: string[]; + err: string[]; +}; + +// Mirrors usesOAuth() in Pi's extensions/mcp/runtime.js: HTTP servers +// authenticate with OAuth unless the config supplies an Authorization header. +function hasAuthorizationHeader(headers: Record | undefined) { + return Object.keys(headers ?? {}).some( + (header) => header.toLowerCase() === "authorization", + ); +} + +function requiredName(name: string) { + if (!name.trim()) { + throw new Error("MCP server name is required."); + } +} + +function toConfigItem( + entry: McpServerEntry, + credentials: McpOAuthCredentialStore, +): McpServerConfigItem { + const { config } = entry; + const http = "url" in config; + const usesOAuth = http && !hasAuthorizationHeader(config.headers); + return { + name: entry.name, + source: entry.source, + enabled: config.enabled !== false, + exposure: config.exposure ?? "codemode", + kind: http ? "http" : "stdio", + transport: http + ? config.url + : [config.command, ...(config.args ?? [])].join(" "), + usesOAuth, + // Read through Pi's own store so "signed in" matches what `pi mcp + // logout` would remove; keyed by the normalized URL. + hasStoredCredentials: + usesOAuth && credentials.tokens(config.url) !== undefined, + }; +} + +export function createMcpServersService( + options: McpServersServiceOptions, +): McpServersService { + const configPath = join(options.agentDir, "mcp.json"); + const openExternal = options.openExternalUrl ?? openUrlInBrowser; + // homedir() as cwd keeps the project file (/.pi/mcp.json) unrelated to + // any real project; servers are additionally filtered to the global file. + const commandBase = { cwd: homedir(), agentDir: options.agentDir }; + + async function runCommand(args: string[]): Promise { + const out: string[] = []; + const err: string[] = []; + const exitCode = await runMcpCommand(args, { + ...commandBase, + // Passing openUrl also makes `login` non-interactive (no stdin prompt); + // other subcommands ignore it. + openUrl: openExternal, + log: (line) => out.push(line), + error: (line) => err.push(line), + }); + return { exitCode, out, err }; + } + + function toActionResult(run: CapturedRun): McpActionResult { + const ok = run.exitCode === 0; + const lines = ok ? run.out : run.err.length > 0 ? run.err : run.out; + return { ok, message: lines.join("\n") }; + } + + async function getConfig(): Promise { + const loaded = loadMcpConfig({ + agentDir: options.agentDir, + cwd: commandBase.cwd, + projectTrusted: false, + }); + // The default store resolves /mcp-auth.json via Pi's getAgentDir(), + // the same store runMcpCommand's commands read. + const credentials = new McpOAuthCredentialStore(); + return { + configPath, + servers: loaded.servers + .filter((entry) => entry.scope === "global" && entry.source === configPath) + .map((entry) => toConfigItem(entry, credentials)), + errors: loaded.errors, + }; + } + + return { + getConfig, + + async probe() { + // `list` exits 1 when any server failed to connect; that is a probe + // result, not a command failure, so the output is still parsed. + const run = await runCommand(["list", "--json"]); + const payload = run.out.find((line) => line.trimStart().startsWith("{")); + let parsed: PiMcpListOutput; + try { + parsed = JSON.parse(payload ?? "") as PiMcpListOutput; + } catch { + throw new Error( + `"pi mcp list --json" output did not parse: ${[...run.out, ...run.err].join("\n")}`, + ); + } + const servers: McpServerProbe[] = (parsed.servers ?? []) + .filter( + (server) => + (server.scope ?? "global") === "global" && server.source === configPath, + ) + .map((server) => ({ + name: server.name, + state: server.state ?? "failed", + tools: server.tools ?? [], + ...(server.toolExposure ? { toolExposure: server.toolExposure } : {}), + ...(server.resources !== undefined + ? { resources: server.resources } + : {}), + ...(server.resourceTemplates !== undefined + ? { resourceTemplates: server.resourceTemplates } + : {}), + ...(server.error ? { error: server.error } : {}), + })); + return { servers, errors: parsed.errors ?? [] }; + }, + + async login(name) { + requiredName(name); + return toActionResult( + await runCommand(["login", name, "--timeout", "120"]), + ); + }, + + async logout(name) { + requiredName(name); + return toActionResult(await runCommand(["logout", name])); + }, + + async setEnabled(name, enabled) { + requiredName(name); + updateMcpServerConfig(configPath, name, { enabled }); + return getConfig(); + }, + + async setExposure(name, exposure) { + requiredName(name); + updateMcpServerConfig(configPath, name, { exposure }); + return getConfig(); + }, + + async add(input) { + requiredName(input.name); + const exposureArgs = input.exposure + ? ["--exposure", input.exposure] + : []; + const args = + input.kind === "http" + ? (() => { + if (!input.url.trim()) { + throw new Error("MCP server URL is required."); + } + return ["add", input.name, ...exposureArgs, "--url", input.url]; + })() + : (() => { + if (!input.command.trim()) { + throw new Error("MCP server command is required."); + } + return [ + "add", + input.name, + ...exposureArgs, + "--", + input.command, + ...input.args, + ]; + })(); + return toActionResult(await runCommand(args)); + }, + + async remove(name) { + requiredName(name); + return toActionResult(await runCommand(["remove", name])); + }, + }; +} diff --git a/packages/backend/src/workspace/open-url.ts b/packages/backend/src/workspace/open-url.ts new file mode 100644 index 00000000..d74e8deb --- /dev/null +++ b/packages/backend/src/workspace/open-url.ts @@ -0,0 +1,17 @@ +import { spawn } from "node:child_process"; + +/** Open a URL in the platform browser without blocking the caller. */ +export function openUrlInBrowser(url: string) { + const platform = process.platform; + if (platform === "darwin") { + spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + return; + } + + if (platform === "win32") { + spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); + return; + } + + spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); +} diff --git a/packages/backend/src/workspace/provider-auth.ts b/packages/backend/src/workspace/provider-auth.ts index e4414790..f071810a 100644 --- a/packages/backend/src/workspace/provider-auth.ts +++ b/packages/backend/src/workspace/provider-auth.ts @@ -4,8 +4,8 @@ // persist to auth.json and the runtime snapshot stays in sync. import { join } from "node:path"; -import { spawn } from "node:child_process"; import { ModelRuntime, readStoredCredential } from "@earendil-works/pi-coding-agent"; +import { openUrlInBrowser } from "./open-url"; import { readSettingsPreferredModel } from "./pi-settings"; import { PROVIDER_DISPLAY_OVERRIDES, @@ -75,21 +75,6 @@ function maskKey(key: string): string { return `…${trimmed.slice(-4)}`; } -function openUrlDefault(url: string) { - const platform = process.platform; - if (platform === "darwin") { - spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); - return; - } - - if (platform === "win32") { - spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); - return; - } - - spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); -} - function modeFromCredential(credential: StoredCredential): ProviderAuthMode { if (!credential) { return "none"; @@ -268,7 +253,7 @@ export function createProviderAuthService( options: ProviderAuthServiceOptions, ): ProviderAuthService { const authPath = join(options.agentDir, "auth.json"); - const openExternal = options.openExternalUrl ?? openUrlDefault; + const openExternal = options.openExternalUrl ?? openUrlInBrowser; const getRuntime = options.runtime; const listStatus = async (): Promise => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f778e79f..08eafbe9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -173,3 +173,14 @@ export { type BrowserAnnotationPayload, type BrowserAnnotationViewport, } from "./browser-annotation"; + +export type { + McpExposure, + McpServerState, + McpServerConfigItem, + McpConfigReport, + McpServerProbe, + McpProbeReport, + McpActionResult, + McpAddServerInput, +} from "./mcp-servers"; diff --git a/packages/core/src/mcp-servers.ts b/packages/core/src/mcp-servers.ts new file mode 100644 index 00000000..792e75c9 --- /dev/null +++ b/packages/core/src/mcp-servers.ts @@ -0,0 +1,76 @@ +// MCP server management contracts for the Settings MCP section. Pace reuses +// Pi's own `pi mcp` implementation (backend path alias @pace/pi-mcp); these +// types mirror Pi's reports so the renderer never imports the engine. + +export type McpExposure = + | "codemode" + | "codemode-deferred" + | "deferred" + | "direct" + | "hidden"; + +export type McpServerState = + | "connected" + | "needs-auth" + | "failed" + | "disconnected" + | "connecting" + | "disabled"; + +/** One server as the global mcp.json defines it, before any connection probe. */ +export type McpServerConfigItem = { + name: string; + /** The mcp.json file that defines the server. */ + source: string; + enabled: boolean; + exposure: McpExposure; + kind: "stdio" | "http"; + /** The command line for stdio servers, the URL for HTTP servers. */ + transport: string; + /** Pi's OAuth rule: HTTP without an Authorization header. */ + usesOAuth: boolean; + /** OAuth tokens for this server's URL exist in Pi's mcp-auth.json. */ + hasStoredCredentials: boolean; +}; + +export type McpConfigReport = { + configPath: string; + servers: McpServerConfigItem[]; + errors: string[]; +}; + +/** One server's probed state from `pi mcp list --json`. */ +export type McpServerProbe = { + name: string; + state: McpServerState; + tools: string[]; + toolExposure?: Record; + resources?: number; + resourceTemplates?: number; + error?: string; +}; + +export type McpProbeReport = { + servers: McpServerProbe[]; + errors: string[]; +}; + +export type McpActionResult = { + ok: boolean; + message: string; +}; + +export type McpAddServerInput = + | { + kind: "stdio"; + name: string; + command: string; + args: string[]; + exposure?: McpExposure; + } + | { + kind: "http"; + name: string; + url: string; + exposure?: McpExposure; + }; diff --git a/tsconfig.json b/tsconfig.json index fc96404d..ac2a8873 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,9 @@ "paths": { "@pace/core": ["packages/core/src/index.ts"], "@pace/backend": ["packages/backend/src/index.ts"], + "@pace/pi-mcp/*": [ + "packages/backend/node_modules/@earendil-works/pi-coding-agent/dist/extensions/mcp/*" + ], "@/*": ["apps/desktop/src/*"] } }, diff --git a/vite.config.ts b/vite.config.ts index 0f3e9f32..f5302e37 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,12 @@ export default defineConfig({ alias: { "@pace/core": resolve(__dirname, "packages/core/src/index.ts"), "@pace/backend": resolve(__dirname, "packages/backend/src/index.ts"), + // Not in Pi's package exports map, so resolved by file path; the alias + // pins the backend to the installed Pi version (contract-tested). + "@pace/pi-mcp": resolve( + __dirname, + "packages/backend/node_modules/@earendil-works/pi-coding-agent/dist/extensions/mcp", + ), "@": resolve(__dirname, "apps/desktop/src"), }, },