diff --git a/README.md b/README.md index b1c43ce..299ffc8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ C:\Users\<你>\.config\opencode\plugins\ai-mcp.ts # Windows | --- | --- | | 工具名 | `mcp_switch` | | 参数 | `action`:`on` / `off` / `status` | -| 效果 | 对调两份配置 → 校验活文件 → `location.reload` 热重载 | +| 效果 | 对调两份配置 → 校验活文件 → **先推送 mcp 配置进 OC** → `location.reload` 热重载 | `status` 只读,任何时候都允许;`on` / `off` 按 `mcpcfg.txt`「模式」键门控: @@ -70,8 +70,16 @@ C:\Users\<你>\.config\opencode\plugins\ai-mcp.ts # Windows | 手动 | ❌ 拒绝,提示用户用 `/mcpon` `/mcpoff` | ✅ 仅此可用 | | 关闭 | ❌ 拒绝 | ❌ 不注册 | +**开启方向是「推送 + reload」双保险(v2 新增)**:对调并写好 `mcp.on.flag` 之后、 +reload 之前,先把活配置里的 `mcp` 推进**运行中的 OC** —— 三连尝试: +`client.config.update({ config: mcp })`(SDK → `PATCH /config`,**无 `/api` 前缀**) +→ `({ body: { mcp } })` → 原生 `fetch PATCH /config`。 +推送成功再 reload,MCP **百分百加载**;推送失败只记日志,自动降级为原 reload 单通道, +不影响后续流程。TUI 插件 `/mcpon`(v9)与 CLI `mcpctl on` 是同一套链。 + reload 走三连尝试:`client.location.reload({location})` → `({})` → 无参 → -兜底 `POST /api/location/reload`。全部失败时配置**已经对调完成**,重启 OpenCode 一样生效。 +兜底 `POST /api/location/reload`。全部失败时配置**已经对调完成**(若推送已成功则 +OC 内已是新配置),重启 OpenCode 一样生效。 --- diff --git a/ai-mcp.ts b/ai-mcp.ts index 23c7b88..1838ac8 100644 --- a/ai-mcp.ts +++ b/ai-mcp.ts @@ -3,7 +3,7 @@ // 职责:通过 mcp_switch 工具,让大模型自己切换「配置对调」状态, // 实现 MCP 的热开/热关(对调 opencode.json 两份配置 + reload 重载)。 // -// 模式(mcpcfg.txt 第1行)语义: +// 模式(mcpcfg.txt「模式」键)语义: // - 自动 auto :大模型可自由调用本工具切换状态;/mcpon /mcpoff 视为手动切换一次 // - 手动 manual:只允许用户用 /mcpon /mcpoff,本工具拒绝切换(status 仍可查) // - 关闭 off :功能整体禁用,本工具拒绝一切操作 @@ -287,8 +287,71 @@ async function doReload(client: any, serverUrl: any) { return { ok: false, why: msg } } +// ---------------------------------------------------------------- P2 兜底:reload 前先把 mcp 配置送进 OC +// 对调只是改了磁盘,reload 读盘时序偶发不稳会导致 MCP 没加载上。 +// 这里在 reload 之前先 PATCH /config 把 mcp 配置推进运行中的 OC, +// 「配置就位」不再依赖 reload —— 推送 + reload 双保险,百分百加载。 +// 推送失败自动降级为原有 reload 单通道,不影响对调主流程。 +async function pushMcpConfig(ctx: any, activePath: string | null): Promise { + try { + if (!activePath) return false + const j = JSON.parse(readFileSync(activePath, "utf8")) + const mcp = j && j.mcp + if (!mcp || !mcp.servers || !Object.keys(mcp.servers).length) return false + const errs: string[] = [] + const c = ctx?.client + if (c && c.config && typeof c.config.update === "function") { + // SDK 签名:parameters.config -> body(PATCH /config,注意无 /api 前缀) + try { + await c.config.update({ config: mcp }) + await slog(c, "info", "mcp 配置已推送进 OC(client.config.update → PATCH /config)") + return true + } catch (e: any) { + errs.push("config.update: " + e.message) + } + try { + await c.config.update({ body: { mcp } }) + await slog(c, "info", "mcp 配置已推送进 OC(body 变体)") + return true + } catch (e: any) { + errs.push("config.update(body): " + e.message) + } + } else { + errs.push("client.config.update 不存在") + } + if (ctx?.serverUrl) { + try { + const res = await fetch(new URL("/config", ctx.serverUrl), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mcp }), + }) + if (res.ok) { + await slog(c, "info", "mcp 配置已推送进 OC(HTTP PATCH /config)") + return true + } + errs.push("http PATCH /config -> " + res.status) + } catch (e: any) { + errs.push("http: " + e.message) + } + } + await slog(c, "warn", "mcp 配置推送未成功(继续走 reload 兜底): " + errs.join("; ")) + return false + } catch (e: any) { + await slog(ctx?.client, "warn", "mcp 配置推送异常(继续走 reload 兜底): " + e.message) + return false + } +} + // ---------------------------------------------------------------- 切换主逻辑 -type SetResult = { ok: boolean; unchanged?: boolean; reloaded?: boolean; why?: string | null; state?: string } +type SetResult = { + ok: boolean + unchanged?: boolean + reloaded?: boolean + pushed?: boolean + why?: string | null + state?: string +} async function setMcp(ctx: any, wantOn: boolean): Promise { const cfg = readMcpCfg() @@ -325,10 +388,13 @@ async function setMcp(ctx: any, wantOn: boolean): Promise { } catch {} await slog(ctx?.client, "info", `配置已对调 -> ${wantOn ? "含 MCP" : "安静(无 MCP)"}`) + // P2 兜底:开启时先推送再 reload(双保险;关闭无需推送) + const pushed = wantOn ? await pushMcpConfig(ctx, cfg.activePath) : undefined const r = await doReload(ctx?.client, ctx?.serverUrl) return { ok: true, reloaded: r.ok, + pushed, state: wantOn ? "开启" : "关闭", why: r.ok ? null : "配置已对调,但热重载调用失败(重启 OC 生效):" + (r.why || ""), } @@ -379,7 +445,7 @@ async function buildHooks(ctx: any) { "action=on 把配置对调为含 MCP 的原始版并重载,让 MCP 连接问题暴露出来便于排查;" + "action=off 对调为不含 MCP 的安静版并重载,恢复零提示的安静状态;" + "action=status 只查询当前模式与 MCP 状态,不改动任何文件。" + - "仅在 mcpcfg.txt 第1行为「自动」时允许切换;「手动」模式请让用户执行 /mcpon 或 /mcpoff;「关闭」模式直接拒绝。", + "仅在 mcpcfg.txt「模式: 自动」时允许切换;「手动」模式请让用户执行 /mcpon 或 /mcpoff;「关闭」模式直接拒绝。", args: { action: toolFn.schema.enum(["on", "off", "status"]), }, @@ -398,7 +464,7 @@ async function buildHooks(ctx: any) { return { title: "MCP 当前状态", output: [ - `模式:${modeTxt}(mcpcfg.txt 第1行)`, + `模式:${modeTxt}(mcpcfg.txt「模式」键)`, `MCP:${hasMcp ? "已开启(配置含 mcp.servers)" : "已关闭(安静,无 mcp 配置)"}`, `活配置文件:${cfg.activePath}`, `对调目标文件:${cfg.onPath === cfg.activePath ? cfg.quietPath : cfg.onPath}`, @@ -412,7 +478,7 @@ async function buildHooks(ctx: any) { if (cfg.mode === "off") { return { title: "MCP 开关被禁用", - output: "拒绝执行:MCP 开关功能已关闭(mcpcfg.txt 第1行=关闭)。如需使用,请让用户把第1行改成「自动」。", + output: "拒绝执行:MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。如需使用,请让用户把「模式」键改成「自动」。", } } @@ -422,7 +488,7 @@ async function buildHooks(ctx: any) { output: "拒绝执行:当前是手动模式,只有用户能切换 MCP 状态。\n" + "请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" + - "如果确实需要大模型自动切换,让用户把 mcpcfg.txt 第1行改成「自动」。", + "如果确实需要大模型自动切换,让用户把 mcpcfg.txt 的「模式」键改成「自动」。", } } @@ -439,7 +505,9 @@ async function buildHooks(ctx: any) { return { title: "MCP 状态未变", output: "无需切换。" + res.why } } const reloadTxt = res.reloaded - ? "配置已对调并完成热重载,MCP 配置立即生效。" + ? res.pushed + ? "mcp 配置已先推送进 OC,再完成对调 + 热重载(双保险),MCP 百分百加载。" + : "配置已对调并完成热重载,MCP 配置立即生效。(推送兜底未生效,已走 reload 单通道)" : "配置已对调,但热重载未成功:" + (res.why || "") + "\n需要重启 OpenCode 才能生效。" return { title: "MCP 已切换", @@ -454,7 +522,7 @@ async function buildHooks(ctx: any) { // ---------------------------------------------------------------- 双导出 // 1) 命名导出:官方文档形式(export const X: Plugin = async (ctx) => ...) export const AiMcp: Plugin = async (ctx) => { - await slog(ctx?.client, "info", "ai-mcp server 插件已加载(命名导出)") + await slog(ctx?.client, "info", "ai-mcp v2 server 插件已加载(命名导出,含推送兜底)") return await buildHooks(ctx) } @@ -463,7 +531,7 @@ export const AiMcp: Plugin = async (ctx) => { export default { id: "ai-mcp", async setup(ctx: any) { - await slog(ctx?.client, "info", "ai-mcp server 插件已加载(default/setup)") + await slog(ctx?.client, "info", "ai-mcp v2 server 插件已加载(default/setup,含推送兜底)") return await buildHooks(ctx) }, }