// ai-mcp —— 给大模型操作的 MCP 开关 server 插件(v4:OC v2 插件契约) // // 职责:通过 mcp_switch 工具,让大模型自己切换「配置对调」状态, // 实现 MCP 的热开/热关(对调 opencode.json 两份配置 + reload 重载)。 // // 模式(mcpcfg.txt「模式」键)语义: // - 自动 auto :大模型可自由调用本工具切换状态;/mcpon /mcpoff 视为手动切换一次 // - 手动 manual:只允许用户用 /mcpon /mcpoff,本工具拒绝切换(status 仍可查) // - 关闭 off :功能整体禁用,本工具拒绝一切操作 // // 与 TUI 插件 oc-session-all 共用同一份 mcpcfg.txt、同一套对调/校验/reload 逻辑。 // // ========================= v4 核心契约(OC 2.x = V2 插件运行时)========================= // 2026-09-23 三重取证定稿(exe 反编译 + opencode.log 40 条崩溃 + 官方 migrate-v1 文档): // // 1) v2 包装器 a8 调用链(opencode.exe 反编译原文): // yield* Ot(ne(()=>Promise.resolve(e.setup(be))), (Ae)=>Ae?ne(()=>Promise.resolve(Ae())):U) // setup 的返回值若 truthy,会被【当 cleanup 函数调用】——返回 hooks 对象 = // TypeError: Ae is not a function ('Ae' is an instance of Object) // -> failed to reload plugins(整批插件注册全挂)。**setup 只能返回 void 或 cleanup 函数**。 // 2) 官方文档:V1 plugin implementations do not run in V2。 // v3.1 的 `return { tool: { mcp_switch } }`(V1 hooks 形态)在 V2 里【没有任何消费者】—— // 这就是「日志显示 mcp_switch 注册成功,但大模型 tools 列表里查不到」的直接根因。 // 3) V2 工具注册的唯一正道:setup 内 `await ctx.tool.transform((editor)=>editor.add({...}))`: // - name + description + input(JSON Schema,不是 zod) // - options.codemode=false【必填级坑】:缺了它工具会被隔离进 Code Mode 的 JS runtime, // 注册"成功"但 session catalog 报 Unknown tool,LLM 常规工具目录里看不到(社区 oh-my-opencode 实战确认) // - execute 返回 { content: string }(V2 结构化返回) // 注册后【下一次模型请求】的工具快照才会带上它(每请求捕获 stable snapshot)。 // // ========================= named export 实验矩阵(勿动 AiMcp 表达式)========================= // 2026-09-23 对 OC 2.0.15 做五组写盘对照(每组固定触发 10 次 watcher 批量 reload, // 全崩或全稳,5/5 一致;崩溃 cause 均为 TypeError: Ae is not a function): // ① 无命名导出 → 崩 ×10,setup 永不执行 // ② body=(await({} as any), undefined as any) → 稳,setup ×10 全成功(实验 A) // ③ body=await buildHooks(ctx)(返回 hooks 对象)→ 崩 ×10(与上面第 1 条根因同源) // ④ 同② body 再次写盘 → 稳,setup ×10 全成功(实验 D,二次复现) // ⑤ body=undefined 直返 → 崩 ×10(推翻「返回 undefined 即安全」的猜测) // 结论:AiMcp body 必须保持 ②/④ 验证过的【逗号表达式原文】。 // 改动此行前必读上面的实验矩阵,改完必须写盘观察 failed Δ+0 且 setup 增长。 import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs" import { homedir } from "node:os" import { basename, dirname, join } from "node:path" const LOG_DIR = join(homedir(), ".config", "opencode") const CFG_TXT = join(LOG_DIR, "mcpcfg.txt") const LOG_FILE = join(LOG_DIR, "ai-mcp.log") const MODE_LABEL: Record = { auto: "自动", manual: "手动", off: "关闭" } // ---------------------------------------------------------------- 模块级留痕 // 只要 OC 真的 import 了本文件,这里立刻写一行 —— // 用来区分「插件没被加载」和「加载了但插件函数没被调用」两种情况。 try { if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }) appendFileSync( LOG_FILE, `[${new Date().toISOString()}] module imported (pid=${process.pid}, node=${process.versions.node})\n`, ) } catch { // 留痕失败绝不影响加载 } // ---------------------------------------------------------------- 本地日志兜底 function fileLog(msg: string) { try { if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }) appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) } catch { // 日志失败绝不影响主流程 } } // 优先走 ctx.app.log(V2 上下文自带;V1 client 也同构),失败回落本地文件 async function slog(ctx: any, level: string, message: string, extra?: any) { try { if (ctx && typeof ctx.app?.log === "function") { await ctx.app.log({ body: { service: "ai-mcp", level, message, extra } }) return } } catch { // 落到下面的文件日志 } fileLog(`${level.toUpperCase()} ${message}${extra ? " " + JSON.stringify(extra) : ""}`) } // ---------------------------------------------------------------- mcpcfg 解析 // 与 tui.js 的 readMcpCfg 保持逐字一致,避免两端行为分叉 // 推荐格式(键值): // # MCP 开关配置 // 模式: 自动 // 含MCP的配置: C:\...\opencode.json // 安静配置: C:\...\opencode.json.2 // 兼容旧「裸三行」,读到旧格式自动迁移写回;路径失效自动扫同目录自愈。 const ON_FLAG = join(LOG_DIR, "mcp.on.flag") // 显式开启标记(与 tui.js 共用) function parseMode(raw: string | undefined) { const key = (raw || "").trim().toLowerCase() if (["auto", "audo", "自动"].includes(key) || key === "") return "auto" if (["manual", "hand", "on", "手动"].includes(key)) return "manual" if (["off", "disable", "disabled", "关闭"].includes(key)) return "off" return "auto" } function writeMcpCfg(mode: string, onPath: string, quietPath: string) { const modeCn = mode === "auto" ? "自动" : mode === "manual" ? "手动" : "关闭" const body = [ "# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)", "# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)", "模式: " + modeCn, "含MCP的配置: " + onPath, "安静配置: " + quietPath, "", ].join("\n") try { writeFileSync(CFG_TXT, body, "utf8") return true } catch (e: any) { fileLog("mcpcfg 写回失败: " + e.message) return false } } function jsonFileHasMcp(p: string) { try { const j = JSON.parse(readFileSync(p, "utf8")) return !!(j && j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0) } catch { return false } } function healShadowPath(activePath: string, missingVal: string): string { try { const dir = dirname(activePath) const activeHas = jsonFileHasMcp(activePath) const cands = readdirSync(dir) .filter((n) => n.startsWith("opencode.json.")) .filter((n) => !n.endsWith(".jsonc") && !n.startsWith(".")) .map((n) => join(dir, n)) .filter((p) => existsSync(p)) const complementary = cands.filter((p) => jsonFileHasMcp(p) !== activeHas) if (complementary.length > 0) return complementary[0] if (cands.length > 0) return cands[0] return missingVal } catch { return missingVal } } // ── 一手防御:mcpcfg.txt 实时监测 + 自动重建(2026-10-12)───────────────── // OC 升级/更新会删掉 ~/.config/opencode 下的非官方文件(实测 mcpcfg.txt 被删 // 导致 /mcpon /mcpoff 整个不注册)。server 插件常驻,这里做 30s 轮询兜底: // 文件丢失 → 探测同目录 opencode.json / 影子配置 → 重建(模式=自动)。 // 只在缺失时动手,绝不覆盖用户手改内容。 function ensureMcpcfgAlive(): boolean { try { if (existsSync(CFG_TXT)) return true const active = join(LOG_DIR, "opencode.json") if (!existsSync(active)) { fileLog("防御: mcpcfg.txt 与 opencode.json 均缺失,无法自动重建") return false } const has = activeHasMcp(active) const shadow = healShadowPath(active, "") if (!shadow || !existsSync(shadow)) { fileLog("防御: mcpcfg.txt 丢失且未找到影子配置,无法自动重建(shadow=" + shadow + ")") return false } const onPath = has ? active : shadow const quietPath = has ? shadow : active if (!writeMcpCfg("auto", onPath, quietPath)) return false fileLog("防御: mcpcfg.txt 丢失已自动重建(模式=自动 on=" + onPath + " quiet=" + quietPath + ")") return true } catch (e: any) { fileLog("ensureMcpcfg 异常(已吞掉): " + (e && e.message ? e.message : String(e))) return false } } function readMcpCfg() { try { if (!existsSync(CFG_TXT)) { return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" } as any } const text = readFileSync(CFG_TXT, "utf8") const allLines = text.split(/\r?\n/) let mode: string let onPath = "" let quietPath = "" let isLegacy = false const kv: Record = {} for (const ln of allLines) { const line = ln.trim() if (!line || line.startsWith("#") || line.startsWith("//")) continue const m = line.match(/^([^::]+)[::]\s*(.*)$/) if (m) kv[m[1].trim().toLowerCase()] = m[2].trim() } const modeVal = kv["模式"] ?? kv["mode"] ?? kv["第1行"] const onVal = kv["含mcp的配置"] ?? kv["含mcp配置"] ?? kv["含mcp"] ?? kv["on"] ?? kv["原始配置"] ?? kv["第2行"] const quietVal = kv["安静配置"] ?? kv["安静"] ?? kv["quiet"] ?? kv["备份"] ?? kv["backup"] ?? kv["第3行"] if (modeVal !== undefined || onVal !== undefined || quietVal !== undefined) { mode = parseMode(modeVal ?? "自动") onPath = (onVal || "").trim() quietPath = (quietVal || "").trim() } else { const bare = allLines.map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("//")) isLegacy = true mode = parseMode(bare[0] ?? "自动") onPath = (bare[1] || "").trim() quietPath = (bare[2] || "").trim() } let activePath: string | null = null let shadowPath: string | null = null if (basename(onPath) === "opencode.json") { activePath = onPath shadowPath = quietPath } else if (basename(quietPath) === "opencode.json") { activePath = quietPath shadowPath = onPath } let healed = false const result: any = { mode, onPath, quietPath, activePath, shadowPath, missing: false, healed: false, error: null, } if (mode !== "off") { // 路径自愈1:没有活文件名 → 找「存在且名为 opencode.json」的那行 if (!activePath) { let guess: string | null = null if (onPath && basename(onPath) === "opencode.json" && existsSync(onPath)) guess = onPath else if (quietPath && basename(quietPath) === "opencode.json" && existsSync(quietPath)) guess = quietPath if (guess) { activePath = guess shadowPath = guess === onPath ? quietPath : onPath healed = true } } // 路径自愈2:活文件存在但影子文件缺失 → 扫同目录互补配置 if (activePath && existsSync(activePath)) { if (!shadowPath || !existsSync(shadowPath)) { const healedShadow = healShadowPath(activePath, shadowPath || "") if (healedShadow && healedShadow !== shadowPath && existsSync(healedShadow)) { if (activePath === onPath) quietPath = healedShadow else onPath = healedShadow shadowPath = healedShadow healed = true } } } result.onPath = onPath result.quietPath = quietPath result.activePath = activePath result.shadowPath = shadowPath result.healed = healed if (activePath && shadowPath && activePath !== shadowPath && existsSync(activePath) && existsSync(shadowPath)) { if (healed || isLegacy) { if (writeMcpCfg(mode, onPath, quietPath)) { fileLog("mcpcfg 已" + (isLegacy && !healed ? "迁移为键值格式" : "自愈修复路径")) result.migrated = isLegacy } } } else if (!activePath) { result.error = "mcpcfg.txt 两行路径都不是 opencode.json(请把「含MCP的配置」或「安静配置」指向名为 opencode.json 的文件)" } else if (!existsSync(activePath)) { result.error = "mcpcfg.txt 指向的活配置不存在: " + activePath } else { result.error = "mcpcfg.txt 缺少第二个配置文件(同目录需要两份 opencode.json 配置才能对调)" } } return result } catch (e: any) { return { mode: "off", error: "读取 mcpcfg.txt 失败: " + e.message } as any } } // 状态判定只看活文件是否含 mcp,与 txt 行语义无关(对调后身份漂移无影响) function activeHasMcp(activePath: string | null) { try { if (!activePath) return false const j = JSON.parse(readFileSync(activePath, "utf8")) return !!(j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0) } catch { return false } } // 交换两文件内容(对调) function swapFiles(a: string, b: string) { const ca = readFileSync(a, "utf8") const cb = readFileSync(b, "utf8") writeFileSync(a, cb) writeFileSync(b, ca) } // ---------------------------------------------------------------- reload 热重载 // 链路:client.location.reload({location}) → ({}) → 无参 → HTTP POST /api/location/reload async function doReload(client: any, serverUrl: any) { const attempts: Array<() => Promise> = [] if (client && client.location && typeof client.location.reload === "function") { attempts.push(() => client.location.reload({ location: undefined as any })) attempts.push(() => client.location.reload({})) attempts.push(() => client.location.reload()) } if (serverUrl) { attempts.push(async () => { const res = await fetch(new URL("/api/location/reload", serverUrl), { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res }) } let lastErr: any = null for (const fn of attempts) { try { await fn() await slog(client, "info", "location.reload 成功(MCP 已热重载)") return { ok: true, how: "reload" } } catch (e: any) { lastErr = e } } const msg = lastErr ? lastErr.message : "没有可用的 reload 通道" await slog(client, "warn", "location.reload 失败: " + msg) return { ok: false, why: msg } } // ---------------------------------------------------------------- GET+merge 工具(与 tui.js 逐字一致) // GET /api/config 返回 document 数组([{type, path, info}])或扁平对象,两形态都容错 function pickConfigInfo(got: any): any { if (Array.isArray(got)) { const doc = got.find((d: any) => d && d.type === "document" && d.info) || got.find((d: any) => d && d.info) return (doc && doc.info) || null } if (got && typeof got === "object") { return got.info && typeof got.info === "object" ? got.info : got } return null } // OC server 口令:service.json 优先(cli.service-config 真实口令,用户名固定 opencode) // → 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD → opencode.json legacy(OC 会忽略) function readServicePassword(): string { try { const sp = join(LOG_DIR, "service.json") if (existsSync(sp)) { const d = JSON.parse(readFileSync(sp, "utf8")) if (d && typeof d.password === "string" && d.password) return d.password } } catch { // 读不到继续降级 } const env: any = (globalThis as any).process?.env || {} if (env.OPENCODE_PASSWORD) return String(env.OPENCODE_PASSWORD) if (env.OPENCODE_SERVER_PASSWORD) return String(env.OPENCODE_SERVER_PASSWORD) try { const d = JSON.parse(readFileSync(join(LOG_DIR, "opencode.json"), "utf8")) const pwd = d && d.server && d.server.password return pwd ? String(pwd) : "" } catch { return "" } } // ---------------------------------------------------------------- 真实状态回读(2026-09-24) // 切换/重载完成后:等一下 → GET /api/mcp 读实测状态,只报真实结果。 // 绝不输出「可能需要重启」之类猜测性文案 —— 那会误导大模型和使用的人 //(实测:OC 2.0.15 的 GET /api/mcp 返回 { location, data: [{ name, status: { status } }] }, // Basic(opencode:service.json 口令) 鉴权)。 function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)) } async function fetchMcpRealStatus(ctx: any, waitMs = 2000): Promise { // 返回形如 ["baota-mcp=connected"] 的实测行;读不到则如实说明原因,不猜。 if (waitMs > 0) await sleep(waitMs) // reload 后 MCP 连接需要一点时间落定 const url = ctx?.serverUrl if (!url) return ["读取失败:上下文没有 serverUrl(本环境拿不到 OC 服务地址)"] try { const pwd = readServicePassword() const headers: Record = { accept: "application/json" } if (pwd) headers["authorization"] = "Basic " + btoa("opencode:" + pwd) const res = await fetch(new URL("/api/mcp", url), { headers }) if (!res.ok) return [`读取失败:GET /api/mcp -> HTTP ${res.status}`] const j: any = await res.json() const list: any[] = Array.isArray(j && j.data) ? j.data : [] if (!list.length) return ["OC 当前没有注册任何 MCP server"] return list.map((s: any) => `${s && s.name ? s.name : "?"}=${s && s.status && s.status.status ? s.status.status : "未知"}`) } catch (e: any) { return ["读取异常:" + (e && e.message ? e.message : String(e))] } } // ---------------------------------------------------------------- P2 兜底:reload 前先把 mcp 配置送进 OC // 对调只是改了磁盘,reload 读盘时序偶发不稳会导致 MCP 没加载上。 // v3 改为 GET+merge:PATCH 的 zod schema 里 shell 是必填键(string|null), // 只送 mcp 会 400「Missing key at ["shell"]」—— 这就是推送通道必须先补 shell 的根因。 // 链路:GET 当前 config 取 shell → 补 shell → PATCH { shell, mcp }; // raw HTTP 兜底也改打真实路由 /api/experimental/config + Basic(opencode:口令) 鉴权。 // 推送失败自动降级为原有 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 = pickClient(ctx) // 1) GET 当前 config → 取 shell 的权威值 let base: any = null if (c && c.config && typeof c.config.get === "function") { try { base = pickConfigInfo(await c.config.get()) } catch (e: any) { errs.push("config.get: " + e.message) } } else { errs.push("client.config.get 不存在") } // 2) 补 shell(必填键):GET 结果 → 活文件 → null,三级兜底 let shell: any = base && typeof base === "object" && "shell" in base ? base.shell : undefined if (shell === undefined) shell = j && "shell" in j ? j.shell : null if (typeof shell !== "string" && shell !== null) shell = null const payload = { shell, mcp } // 3) PATCH(shell 已补齐) if (c && c.config && typeof c.config.update === "function") { try { await c.config.update({ config: payload }) await slog(ctx, "info", `mcp 配置已推送进 OC(GET+merge → PATCH /config,shell=${JSON.stringify(shell)})`) return true } catch (e: any) { errs.push("config.update: " + e.message) } try { await c.config.update({ body: payload }) await slog(ctx, "info", "mcp 配置已推送进 OC(body 变体)") return true } catch (e: any) { errs.push("config.update(body): " + e.message) } } else { errs.push("client.config.update 不存在") } // raw HTTP 兜底:真实路由是 PATCH /api/experimental/config(/config 会 405) if (ctx?.serverUrl) { try { const pwd = readServicePassword() // directory 归一为正斜杠(Windows 反斜杠在 query 里不被认) const dir = encodeURIComponent(dirname(activePath).replace(/\\/g, "/")) const headers: Record = { accept: "application/json" } if (pwd) headers["authorization"] = "Basic " + btoa("opencode:" + pwd) let rawShell: any = undefined try { const g = await fetch(new URL(`/api/config?directory=${dir}`, ctx.serverUrl), { headers }) if (g.ok) { const info = pickConfigInfo(await g.json()) if (info && typeof info === "object" && "shell" in info) rawShell = info.shell } } catch { // GET 失败走兜底 } if (rawShell === undefined) rawShell = shell const res = await fetch(new URL(`/api/experimental/config?directory=${dir}`, ctx.serverUrl), { method: "PATCH", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ shell: rawShell, mcp }), }) if (res.ok) { await slog(ctx, "info", "mcp 配置已推送进 OC(HTTP PATCH /api/experimental/config)") return true } errs.push(`http PATCH /api/experimental/config -> ${res.status}`) } catch (e: any) { errs.push("http: " + e.message) } } await slog(ctx, "warn", "mcp 配置推送未成功(reload 主通道继续): " + errs.join("; ")) return false } catch (e: any) { await slog(ctx, "warn", "mcp 配置推送异常(reload 主通道继续): " + e.message) return false } } // V2 上下文没有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身 //(doReload/pushMcpConfig 只用到 location.reload / serverUrl / app.log,ctx 均具备同构能力) function pickClient(ctx: any) { if (!ctx) return ctx if (ctx.client && (ctx.client.location || ctx.client.config)) return ctx.client return ctx } // ---------------------------------------------------------------- 切换主逻辑 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() if (cfg.error) return { ok: false, why: cfg.error } if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(mcpcfg.txt「模式: 关闭」)" } const cur = activeHasMcp(cfg.activePath) if (cur === wantOn) { // 状态已一致,但显式意图要落标记(避免 TUI 侧下次启动归位把它换走) try { if (wantOn) writeFileSync(ON_FLAG, String(process.pid)) else if (existsSync(ON_FLAG)) rmSync(ON_FLAG) } catch {} return { ok: true, unchanged: true, state: wantOn ? "开启" : "关闭", why: wantOn ? "MCP 已是开启状态" : "MCP 已是关闭(安静)状态", } } swapFiles(cfg.onPath, cfg.quietPath) const now = activeHasMcp(cfg.activePath) if (now !== wantOn) { await slog(ctx, "error", `配置对调后状态校验失败 want=${wantOn} got=${now}`) return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 两行路径)" } } // 记录显式意图:on 写标记、off 删标记(与 tui.js 共用 mcp.on.flag) try { if (wantOn) writeFileSync(ON_FLAG, String(process.pid)) else if (existsSync(ON_FLAG)) rmSync(ON_FLAG) } catch {} await slog(ctx, "info", `配置已对调 -> ${wantOn ? "含 MCP" : "安静(无 MCP)"}`) // P2 兜底:开启时先推送再 reload(双保险;关闭无需推送) const pushed = wantOn ? await pushMcpConfig(ctx, cfg.activePath) : undefined const c = pickClient(ctx) const r = await doReload(c, ctx?.serverUrl) return { ok: true, reloaded: r.ok, pushed, state: wantOn ? "开启" : "关闭", why: r.ok ? null : "配置已对调,但热重载调用失败:" + (r.why || ""), } } function modeLabel(m: string) { return MODE_LABEL[m] || m } // ---------------------------------------------------------------- 工具本体(V2 execute:返回 { content }) // 逻辑与 v3.1 逐字一致,仅两点适配 V2: // - 日志上下文从 ctx.client 换成 V2 ctx(slog 内部 ctx.app.log) // - 返回 { content: string }(V2 结构化返回,官方 migrate-v1 文档口径) async function runSwitch(ctx: any, action: string): Promise { try { const cfg = readMcpCfg() if (cfg.error) { return "MCP 开关失败:失败:" + cfg.error } const hasMcp = activeHasMcp(cfg.activePath) const modeTxt = modeLabel(cfg.mode) if (action === "status") { // 磁盘配置态之外,再读一份 MCP 真实连接状态(GET /api/mcp 实测,不做猜测) const realLines = await fetchMcpRealStatus(ctx, 0) return [ "MCP 当前状态", `模式:${modeTxt}(mcpcfg.txt「模式」键)`, `MCP:${hasMcp ? "已开启(配置含 mcp.servers)" : "已关闭(安静,无 mcp 配置)"}`, `真实状态(GET /api/mcp 实测):${realLines.join(";")}`, `活配置文件:${cfg.activePath}`, `对调目标文件:${cfg.onPath === cfg.activePath ? cfg.quietPath : cfg.onPath}`, cfg.mode === "manual" ? "提示:手动模式下只能由用户执行 /mcpon 或 /mcpoff。" : "", ] .filter(Boolean) .join("\n") } if (cfg.mode === "off") { return "MCP 开关被禁用:拒绝执行,MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。如需使用,请让用户把「模式」键改成「自动」。" } if (cfg.mode === "manual") { return ( "手动模式拒绝:当前是手动模式,只有用户能切换 MCP 状态。\n" + "请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" + "如果确实需要大模型自动切换,让用户把 mcpcfg.txt 的「模式」键改成「自动」。" ) } // auto 模式:执行切换 const wantOn = action === "on" await slog(ctx, "info", `mcp_switch 收到 action=${action}(自动模式)`) const res = await setMcp(ctx, wantOn) if (!res.ok) { await slog(ctx, "error", "mcp_switch 失败: " + res.why) return "MCP 切换失败:失败:" + res.why } if (res.unchanged) { return "MCP 状态未变:无需切换。" + res.why } // 重载完成后等一下,读 MCP 真实状态(GET /api/mcp 实测)—— // 只报真实结果,绝不输出「可能需要重启」之类猜测性文案(会误导大模型和使用的人) const realLines = await fetchMcpRealStatus(ctx, res.reloaded ? 2000 : 0) const realTxt = "真实状态(GET /api/mcp 实测):" + realLines.join(";") const reloadTxt = res.reloaded ? res.pushed ? "mcp 配置已先推送进 OC,再完成对调 + 热重载(双保险)。" : "配置已对调并完成热重载。" : "配置已对调,但热重载调用未成功:" + (res.why || "") return `MCP 已切换:已把 MCP 切换为「${res.state}」状态。\n${reloadTxt}\n${realTxt}` } catch (e: any) { // 最后一道防线:绝不把异常抛回 OC 的工具执行链路 const msg = e && e.message ? e.message : String(e) try { await slog(ctx, "error", "mcp_switch 执行异常(已兜底): " + msg) } catch {} return "MCP 开关执行异常(已兜底):" + msg } } const MCP_SWITCH_DESC = "切换 OpenCode 的 MCP 开关状态(配置对调 + 热重载)。" + "action=on 把配置对调为含 MCP 的原始版并重载,让 MCP 连接问题暴露出来便于排查;" + "action=off 对调为不含 MCP 的安静版并重载,恢复零提示的安静状态;" + "action=status 只查询当前模式与 MCP 状态,不改动任何文件。" + "仅在 mcpcfg.txt「模式: 自动」时允许切换;「手动」模式请让用户执行 /mcpon 或 /mcpoff;「关闭」模式直接拒绝。" // ---------------------------------------------------------------- 双导出(named export 实验矩阵见文件头) // AiMcp 必须保持 ②/④ 验证过的逗号表达式原文 —— 改动前必读文件头实验矩阵。 export const AiMcp: any = async (ctx: any) => (await ({} as any), undefined as any) // default 是 OC v2 加载器唯一读取的入口:{ id, setup }。 // setup 内通过 ctx.tool.transform 注册工具;【setup 绝不能返回 hooks 对象】 //(返回值 truthy 会被包装器当 cleanup 函数调用 -> 整批插件 reload 失败,见文件头契约第 1 条)。 export default { id: "ai-mcp", async setup(ctx: any): Promise { // setup 是 OC activate 阶段的调用点:任何抛出都会变成 // "failed to reload plugins",把整批插件重载一起打挂 —— 这里必须绝对不抛。 try { if (!ctx || !ctx.tool || typeof ctx.tool.transform !== "function") { await slog( ctx, "error", "当前宿主没有 ctx.tool.transform(非 V2 插件上下文或版本过旧),mcp_switch 未注册", ) return } const registration = await ctx.tool.transform((editor: any) => { editor.add({ name: "mcp_switch", description: MCP_SWITCH_DESC, // V2 工具入参是 JSON Schema(不是 zod) input: { type: "object", properties: { action: { type: "string", enum: ["on", "off", "status"], description: "on=开启 MCP;off=关闭 MCP;status=只查状态", }, }, required: ["action"], additionalProperties: false, }, // codemode:false = 关键坑位:不加会被隔离进 Code Mode JS runtime, // 注册成功但 LLM 常规工具目录查不到(Unknown tool) options: { codemode: false }, async execute(input: any) { const action = String(input?.action || "status") const text = await runSwitch(ctx, action) return { content: text } }, }) }) await slog( ctx, "info", "ai-mcp v4 setup 完成:mcp_switch 已经 ctx.tool.transform 注册" + "(JSON Schema input + options.codemode=false + execute 返回 {content})" + (registration ? ",registration 已持有" : ""), ) // 一手防御轮询:mcpcfg.txt 被 OC 更新删除时自动重建(30s;健康时仅一次 existsSync) try { ensureMcpcfgAlive() setInterval(() => { try { ensureMcpcfgAlive() } catch {} }, 30000) } catch {} // 不返回任何值(返回 cleanup 函数是允许的,但我们没有需要清理的资源; // 返回 hooks 对象 = 触发 Ae is not a function 整批崩溃) return } catch (e: any) { const msg = e && e.message ? e.message : String(e) try { await slog(ctx, "error", "ai-mcp setup 异常(已吞掉,不影响其它插件): " + msg) } catch {} return } }, }