ai-mcp:给大模型操作的 MCP 开关 server 插件
- 注册 mcp_switch 工具(action = on / off / status),供大模型自主切换 MCP 状态
- 与 TUI 插件共用同一份 mcpcfg.txt、同一套对调与校验逻辑,状态判定只看活文件是否含 mcp.servers
- 模式门控:自动=允许切换;手动=拒绝并提示用户用 /mcpon /mcpoff;关闭=一律拒绝(status 始终可查)
- 切换 = 对调两份配置 + 校验 + reload 热重载(SDK location.reload 三连尝试,失败回落 POST /api/location/reload)
- 双导出:命名导出 AiMcp(文档形式)与 default { id, setup }(v2 校验形式),保证任一加载器都能注册成功
- tool helper 走动态 import + 降级日志,WSL 端缺 node_modules 时不会让整个插件加载失败
- 日志优先 client.app.log,失败回落 ~/.config/opencode/ai-mcp.log
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 构建/依赖产物
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# 编辑器与系统
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,310 @@
|
||||
// ai-mcp —— 给大模型操作的 MCP 开关 server 插件
|
||||
//
|
||||
// 职责:通过 mcp_switch 工具,让大模型自己切换「配置对调」状态,
|
||||
// 实现 MCP 的热开/热关(对调 opencode.json 两份配置 + reload 重载)。
|
||||
//
|
||||
// 模式(mcpcfg.txt 第1行)语义:
|
||||
// - 自动 auto :大模型可自由调用本工具切换状态;/mcpon /mcpoff 视为手动切换一次
|
||||
// - 手动 manual:只允许用户用 /mcpon /mcpoff,本工具拒绝切换(status 仍可查)
|
||||
// - 关闭 off :功能整体禁用,本工具拒绝一切操作
|
||||
//
|
||||
// 与 TUI 插件 oc-session-all 共用同一份 mcpcfg.txt、同一套对调/校验/reload 逻辑。
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { basename, 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: "关闭" }
|
||||
|
||||
// ---------------------------------------------------------------- 本地日志兜底
|
||||
function fileLog(msg: string) {
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
|
||||
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
|
||||
} catch {
|
||||
// 日志失败绝不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 优先走 client.app.log(结构化),失败回落本地文件
|
||||
async function slog(client: any, level: string, message: string, extra?: any) {
|
||||
try {
|
||||
if (client && typeof client.app?.log === "function") {
|
||||
await client.app.log({ body: { service: "ai-mcp", level, message, extra } })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 落到下面的文件日志
|
||||
}
|
||||
fileLog(`${level.toUpperCase()} ${message}${extra ? " " + JSON.stringify(extra) : ""}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mcpcfg 解析
|
||||
// 与 tui.js 的 readMcpCfg 保持逐字一致,避免两端行为分叉
|
||||
function readMcpCfg() {
|
||||
try {
|
||||
if (!existsSync(CFG_TXT)) {
|
||||
return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" } as any
|
||||
}
|
||||
const lines = readFileSync(CFG_TXT, "utf8").split(/\r?\n/)
|
||||
const key = (lines[0] || "").trim().toLowerCase()
|
||||
let mode: string
|
||||
if (["auto", "audo", "自动"].includes(key) || key === "") mode = "auto"
|
||||
else if (["manual", "hand", "on", "手动"].includes(key)) mode = "manual"
|
||||
else if (["off", "disable", "disabled", "关闭"].includes(key)) mode = "off"
|
||||
else mode = "auto"
|
||||
|
||||
const onPath = (lines[1] || "").trim()
|
||||
const quietPath = (lines[2] || "").trim()
|
||||
|
||||
// 活文件 = 两行中文件名为 opencode.json 的那份(OC 只读这个名字)
|
||||
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
|
||||
}
|
||||
|
||||
const result: any = { mode, onPath, quietPath, activePath, shadowPath, missing: false, error: null }
|
||||
if (mode !== "off" && !activePath) {
|
||||
result.error = "mcpcfg.txt 第2/3行必须有一个是 opencode.json 的完整路径"
|
||||
} else if (mode !== "off" && (!existsSync(onPath) || !existsSync(quietPath))) {
|
||||
result.error = "mcpcfg.txt 第2/3行指向的文件不存在(请检查路径)"
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 切换主逻辑
|
||||
type SetResult = { ok: boolean; unchanged?: boolean; reloaded?: 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 第1行=关闭)" }
|
||||
|
||||
const cur = activeHasMcp(cfg.activePath)
|
||||
if (cur === wantOn) {
|
||||
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?.client, "error", `配置对调后状态校验失败 want=${wantOn} got=${now}`)
|
||||
return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 第2/3行路径)" }
|
||||
}
|
||||
|
||||
await slog(ctx?.client, "info", `配置已对调 -> ${wantOn ? "含 MCP" : "安静(无 MCP)"}`)
|
||||
const r = await doReload(ctx?.client, ctx?.serverUrl)
|
||||
return {
|
||||
ok: true,
|
||||
reloaded: r.ok,
|
||||
state: wantOn ? "开启" : "关闭",
|
||||
why: r.ok ? null : "配置已对调,但热重载调用失败(重启 OC 生效):" + (r.why || ""),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tool helper 装载
|
||||
// 动态 import:WSL 端 plugins/ 可能没有 node_modules,绝不能让整文件加载崩掉
|
||||
let toolHelper: any = null
|
||||
let toolHelperErr: string | null = null
|
||||
|
||||
async function loadToolHelper(): Promise<any> {
|
||||
if (toolHelper) return toolHelper
|
||||
try {
|
||||
const mod: any = await import("@opencode-ai/plugin")
|
||||
if (mod && typeof mod.tool === "function") {
|
||||
toolHelper = mod.tool
|
||||
return toolHelper
|
||||
}
|
||||
toolHelperErr = "@opencode-ai/plugin 未导出 tool 方法"
|
||||
} catch (e: any) {
|
||||
toolHelperErr = e && e.message ? e.message : String(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function modeLabel(m: string) {
|
||||
return MODE_LABEL[m] || m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具本体
|
||||
async function buildHooks(ctx: any) {
|
||||
const toolFn = await loadToolHelper()
|
||||
if (!toolFn) {
|
||||
await slog(
|
||||
ctx?.client,
|
||||
"error",
|
||||
"无法加载 @opencode-ai/plugin,mcp_switch 工具未注册。请在 plugins/ 目录执行依赖安装(bun install / npm install)。原因:" +
|
||||
toolHelperErr,
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
tool: {
|
||||
mcp_switch: toolFn({
|
||||
description:
|
||||
"切换 OpenCode 的 MCP 开关状态(配置对调 + 热重载)。" +
|
||||
"action=on 把配置对调为含 MCP 的原始版并重载,让 MCP 连接问题暴露出来便于排查;" +
|
||||
"action=off 对调为不含 MCP 的安静版并重载,恢复零提示的安静状态;" +
|
||||
"action=status 只查询当前模式与 MCP 状态,不改动任何文件。" +
|
||||
"仅在 mcpcfg.txt 第1行为「自动」时允许切换;「手动」模式请让用户执行 /mcpon 或 /mcpoff;「关闭」模式直接拒绝。",
|
||||
args: {
|
||||
action: toolFn.schema.enum(["on", "off", "status"]),
|
||||
},
|
||||
async execute(args: any, _context: any) {
|
||||
const action = String(args?.action || "status")
|
||||
const cfg = readMcpCfg()
|
||||
|
||||
if (cfg.error) {
|
||||
return { title: "MCP 开关失败", output: "失败:" + cfg.error }
|
||||
}
|
||||
|
||||
const hasMcp = activeHasMcp(cfg.activePath)
|
||||
const modeTxt = modeLabel(cfg.mode)
|
||||
|
||||
if (action === "status") {
|
||||
return {
|
||||
title: "MCP 当前状态",
|
||||
output: [
|
||||
`模式:${modeTxt}(mcpcfg.txt 第1行)`,
|
||||
`MCP:${hasMcp ? "已开启(配置含 mcp.servers)" : "已关闭(安静,无 mcp 配置)"}`,
|
||||
`活配置文件:${cfg.activePath}`,
|
||||
`对调目标文件:${cfg.onPath === cfg.activePath ? cfg.quietPath : cfg.onPath}`,
|
||||
cfg.mode === "manual" ? "提示:手动模式下只能由用户执行 /mcpon 或 /mcpoff。" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.mode === "off") {
|
||||
return {
|
||||
title: "MCP 开关被禁用",
|
||||
output: "拒绝执行:MCP 开关功能已关闭(mcpcfg.txt 第1行=关闭)。如需使用,请让用户把第1行改成「自动」。",
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.mode === "manual") {
|
||||
return {
|
||||
title: "手动模式拒绝",
|
||||
output:
|
||||
"拒绝执行:当前是手动模式,只有用户能切换 MCP 状态。\n" +
|
||||
"请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" +
|
||||
"如果确实需要大模型自动切换,让用户把 mcpcfg.txt 第1行改成「自动」。",
|
||||
}
|
||||
}
|
||||
|
||||
// auto 模式:执行切换
|
||||
const wantOn = action === "on"
|
||||
await slog(ctx?.client, "info", `mcp_switch 收到 action=${action}(自动模式)`)
|
||||
const res = await setMcp(ctx, wantOn)
|
||||
|
||||
if (!res.ok) {
|
||||
await slog(ctx?.client, "error", "mcp_switch 失败: " + res.why)
|
||||
return { title: "MCP 切换失败", output: "失败:" + res.why }
|
||||
}
|
||||
if (res.unchanged) {
|
||||
return { title: "MCP 状态未变", output: "无需切换。" + res.why }
|
||||
}
|
||||
const reloadTxt = res.reloaded
|
||||
? "配置已对调并完成热重载,MCP 配置立即生效。"
|
||||
: "配置已对调,但热重载未成功:" + (res.why || "") + "\n需要重启 OpenCode 才能生效。"
|
||||
return {
|
||||
title: "MCP 已切换",
|
||||
output: `已把 MCP 切换为「${res.state}」状态。\n${reloadTxt}`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 双导出
|
||||
// 1) 命名导出:官方文档形式(export const X: Plugin = async (ctx) => ...)
|
||||
export const AiMcp: Plugin = async (ctx) => {
|
||||
await slog(ctx?.client, "info", "ai-mcp server 插件已加载(命名导出)")
|
||||
return await buildHooks(ctx)
|
||||
}
|
||||
|
||||
// 2) default 导出:v2 校验形式({ id, setup }),与命名导出返回同一套 hooks,
|
||||
// 无论加载器认哪一种,mcp_switch 都能注册成功(同名工具后者覆盖,行为一致)。
|
||||
export default {
|
||||
id: "ai-mcp",
|
||||
async setup(ctx: any) {
|
||||
await slog(ctx?.client, "info", "ai-mcp server 插件已加载(default/setup)")
|
||||
return await buildHooks(ctx)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user