Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .scratch/mcp-extension-ui/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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 适配的前两步已经完成,第三步有设计取舍。本文把已查明的事实和待决问题交给新会话,避免重查。

Expand Down
52 changes: 52 additions & 0 deletions .scratch/mcp-settings/PRD.md
Original file line number Diff line number Diff line change
@@ -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 数据。
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};

Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions packages/backend/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import type {
ExecutionCheckoutGitClient,
McpAddServerInput,
McpExposure,
ModelCatalogInvalidatedPayload,
ProviderAuthId,
PromptCommandCatalog,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -140,6 +146,7 @@ export type BackendServiceOptions = {
piSessionListAll?: () => Promise<PiSessionListItem[]>;
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). */
Expand Down Expand Up @@ -235,6 +242,7 @@ export function createBackendService(options: BackendServiceOptions = {}): Backe
agentDir,
runtime: getModelRuntime,
});
const mcpServers = options.mcpServers ?? createMcpServersService({ agentDir });
const piSessionListAll =
options.piSessionListAll ??
(async () => {
Expand Down Expand Up @@ -375,6 +383,7 @@ export function createBackendService(options: BackendServiceOptions = {}): Backe
staticPromptCommands,
environmentPreflight,
providerAuth,
mcpServers,
piSessionListAll,
runtimeGateway,
runtimeDriver,
Expand Down Expand Up @@ -416,6 +425,7 @@ async function dispatchRequest(input: {
staticPromptCommands: StaticPromptCommandsResolver;
environmentPreflight: EnvironmentPreflightReader;
providerAuth: ProviderAuthService;
mcpServers: McpServersService;
piSessionListAll: () => Promise<PiSessionListItem[]>;
runtimeGateway: RuntimeGatewayService;
runtimeDriver: PiRuntimeDriver;
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/workspace/fixtures/mcp-test-server.d.mts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};
}): unknown;
Loading
Loading