Files
oc_session/ai-mcp.ts
T

696 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, string> = { 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
}
}
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<string, string> = {}
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<any>> = []
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<void> {
return new Promise((r) => setTimeout(r, ms))
}
async function fetchMcpRealStatus(ctx: any, waitMs = 2000): Promise<string[]> {
// 返回形如 ["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<string, string> = { 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<boolean> {
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<string, string> = { 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<SetResult> {
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<string> {
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<void> {
// 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 已持有" : ""),
)
// 不返回任何值(返回 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
}
},
}