From 5378d48095d00e3db96ef7556383ca844a2a270b Mon Sep 17 00:00:00 2001 From: luckyyzh Date: Sun, 2 Aug 2026 22:27:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20webUI=20MCP=20=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF=20+=20README=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + pi-web/app/api/mcp/route.ts | 58 +++ pi-web/components/AppShell.tsx | 14 + pi-web/components/McpConfig.tsx | 723 ++++++++++++++++++++++++++++++ pi-web/lib/i18n/messages/en.ts | 22 + pi-web/lib/i18n/messages/zh-CN.ts | 22 + 6 files changed, 843 insertions(+) create mode 100644 pi-web/app/api/mcp/route.ts create mode 100644 pi-web/components/McpConfig.tsx diff --git a/README.md b/README.md index de67ba7..75c0acd 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ data/workspaces/default/ 默认工作目录 Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。 +MCP 服务器可通过 Web UI 左下角的 MCP 按钮可视化配置(写入 `data/agent/mcp.json`):支持 stdio(命令 + 参数)与 HTTP(URL + 请求头 + OAuth/Bearer)两种传输、环境变量键值编辑、工作目录、生命周期与超时设置,另保留原始 JSON 编辑兜底。保存后重启 pi(或 /reload)生效。 + #### 记忆模式 默认设置 `PI_MEMORY_NO_SEARCH=1` 和 `PI_MEMORY_QMD_UPDATE=off`。这不会削弱 Markdown 记忆、每日日志、scratchpad 或恢复记录,但会跳过 qmd 探测、安装提示和 `memory_search` 工具,因此全新安装不会自动下载 qmd 的本地模型。集成补丁会在 `setup` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。 @@ -320,6 +322,8 @@ Versions are pinned in `config/settings.default.json` and `config/mcp.default.js Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge. +MCP servers can be configured visually from the MCP button in the lower-left Web UI (writes `data/agent/mcp.json`): stdio (command + args) or HTTP (URL + headers + OAuth/Bearer) transport, environment-variable row editing, working directory, lifecycle and timeout options, plus raw JSON editing as a fallback. Changes take effect after restarting pi (or /reload). + #### Memory modes The managed launcher defaults to `PI_MEMORY_NO_SEARCH=1` and `PI_MEMORY_QMD_UPDATE=off`. Markdown memory, daily logs, scratchpad, and recovery stay available, while qmd detection, its installation notice, and the `memory_search` tool are skipped. A deterministic integration patch is checked during setup and before every launch, so reinstalling managed packages needs no manual repair. diff --git a/pi-web/app/api/mcp/route.ts b/pi-web/app/api/mcp/route.ts new file mode 100644 index 0000000..2225f74 --- /dev/null +++ b/pi-web/app/api/mcp/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; + +export const dynamic = "force-dynamic"; + +function getMcpPath(): string { + return join(getAgentDir(), "mcp.json"); +} + +interface McpConfigFile { + mcpServers: Record>; + settings?: Record; + imports?: string[]; +} + +function readMcpJson(): McpConfigFile { + const path = getMcpPath(); + if (!existsSync(path)) return { mcpServers: {} }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile; + if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") { + return { mcpServers: {} }; + } + return parsed; + } catch { + return { mcpServers: {} }; + } +} + +function writeMcpJson(data: McpConfigFile): void { + const path = getMcpPath(); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +export function GET() { + return NextResponse.json({ config: readMcpJson(), path: getMcpPath() }); +} + +export async function PUT(req: Request) { + try { + const body = (await req.json()) as McpConfigFile; + if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) { + return NextResponse.json({ error: "mcpServers must be an object" }, { status: 400 }); + } + // 只保留已知顶层字段,避免写入无关键 + const out: McpConfigFile = { mcpServers: body.mcpServers }; + if (body.settings && typeof body.settings === "object") out.settings = body.settings; + if (Array.isArray(body.imports)) out.imports = body.imports; + writeMcpJson(out); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/pi-web/components/AppShell.tsx b/pi-web/components/AppShell.tsx index 1866ee7..3aa6ef4 100644 --- a/pi-web/components/AppShell.tsx +++ b/pi-web/components/AppShell.tsx @@ -8,6 +8,7 @@ import { ChatWindow } from "./ChatWindow"; import { FileViewer } from "./FileViewer"; import { TabBar, type Tab } from "./TabBar"; import { ModelsConfig } from "./ModelsConfig"; +import { McpConfig } from "./McpConfig"; import { SkillsConfig } from "./SkillsConfig"; import { PluginsConfig } from "./PluginsConfig"; import { ProjectTrustDialog } from "./ProjectTrustDialog"; @@ -53,6 +54,7 @@ export function AppShell() { const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); const [modelsConfigOpen, setModelsConfigOpen] = useState(false); const [modelsRefreshKey, setModelsRefreshKey] = useState(0); + const [mcpConfigOpen, setMcpConfigOpen] = useState(false); const [skillsConfigOpen, setSkillsConfigOpen] = useState(false); const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false); const [projectTrust, setProjectTrust] = useState(null); @@ -578,6 +580,17 @@ export function AppShell() { ), }, + { + label: translate("common.mcp"), + onClick: () => setMcpConfigOpen(true), + disabled: false, + icon: ( + + + + + ), + }, { label: translate("common.skills"), onClick: () => setSkillsConfigOpen(true), @@ -1514,6 +1527,7 @@ export function AppShell() { {modelsConfigOpen && { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />} + {mcpConfigOpen && setMcpConfigOpen(false)} />} {projectTrustDialogOpen && projectTrustCwd && ( ; + cwd?: string; + url?: string; + headers?: Record; + auth?: "oauth" | "bearer" | false; + bearerToken?: string; + bearerTokenEnv?: string; + lifecycle?: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager"; + idleTimeout?: number; + requestTimeoutMs?: number; + exposeResources?: boolean; + directTools?: boolean | string[]; + includeTools?: string[]; + excludeTools?: string[]; + debug?: boolean; + trace?: boolean; + disabled?: boolean; + [key: string]: unknown; +} + +interface McpConfigFile { + mcpServers: Record; + settings?: Record; + imports?: string[]; +} + +type TransportKind = "stdio" | "http"; + +function entrySummary(entry: McpServerEntry): string { + if (entry.url) { + try { + return new URL(entry.url).host || entry.url; + } catch { + return entry.url; + } + } + if (entry.socket) return entry.socket; + const cmd = entry.command ?? ""; + const args = entry.args?.length ? ` ${entry.args.join(" ")}` : ""; + return `${cmd}${args}`; +} + +function transportOf(entry: McpServerEntry): TransportKind { + return entry.url ? "http" : "stdio"; +} + +function envToRows(env?: Record): Array<{ key: string; value: string }> { + return Object.entries(env ?? {}).map(([key, value]) => ({ key, value })); +} + +function rowsToEnv(rows: Array<{ key: string; value: string }>): Record { + const env: Record = {}; + for (const row of rows) { + const key = row.key.trim(); + if (key) env[key] = row.value; + } + return env; +} + +// ── Form helpers ───────────────────────────────────────────────────────────── + +const inputStyle: React.CSSProperties = { + padding: "6px 9px", + background: "var(--bg-panel)", + border: "1px solid var(--border)", + borderRadius: 5, + color: "var(--text)", + fontSize: 12, + outline: "none", + width: "100%", + boxSizing: "border-box", +}; + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }} + /> + ); +} + +function TextArea({ + value, + onChange, + rows, + placeholder, + mono, +}: { + value: string; + onChange: (v: string) => void; + rows: number; + placeholder?: string; + mono?: boolean; +}) { + return ( +