Files
oc_session/ai-mcp.ts
T
ypshidifu 86f0d9e84c ai-mcp:补模块级留痕日志 + v2 注册说明
- 顶层加 module imported 留痕:区分「没被加载」和「加载了但没被调用」
- 新增 README,记录实测结论:
  * OpenCode v2 不再自动扫描全局 plugins/ 下的一级文件,必须在 opencode.json
    的 plugins 数组用 file:/// 显式注册,否则文件根本不会被 import
  * 两份配置(含 MCP 版 / 安静版)都要注册,否则 mcpoff 对调后插件消失
  * 双导出的原因:v2 对 default 有 schema 校验(opencode-btw 就因缺 default 反复报错)
  * 插件在 session 初始化时才加载,只启动 serve 不建 session 看不到日志
2026-09-23 12:24:31 +08:00

324 lines
13 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 插件
//
// 职责:通过 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: "关闭" }
// ---------------------------------------------------------------- 模块级留痕
// 只要 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 {
// 日志失败绝不影响主流程
}
}
// 优先走 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)
},
}