ai-mcp v3.1:三态写盘实验锁定双导出配方,buildHooks/setup/execute全链防抛+纯字符串返回
This commit is contained in:
@@ -10,7 +10,6 @@
|
||||
//
|
||||
// 与 TUI 插件 oc-session-all 共用同一份 mcpcfg.txt、同一套对调/校验/reload 逻辑。
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { basename, dirname, join } from "node:path"
|
||||
@@ -499,7 +498,22 @@ function modeLabel(m: string) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具本体
|
||||
async function buildHooks(ctx: any) {
|
||||
// v3.1 加固:buildHooks 绝不向外抛 —— OC 在 activate 阶段调 setup,
|
||||
// 任何抛出都会变成 "failed to reload plugins" 让整批插件重载失败。
|
||||
async function buildHooks(ctx: any): Promise<any> {
|
||||
try {
|
||||
return await buildHooksInner(ctx)
|
||||
} catch (e: any) {
|
||||
await slog(
|
||||
ctx?.client,
|
||||
"error",
|
||||
"ai-mcp buildHooks 异常(已吞掉,OC 不受影响): " + (e && e.message ? e.message : String(e)),
|
||||
)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHooksInner(ctx: any) {
|
||||
const toolFn = await loadToolHelper()
|
||||
if (!toolFn) {
|
||||
await slog(
|
||||
@@ -523,47 +537,43 @@ async function buildHooks(ctx: any) {
|
||||
args: {
|
||||
action: toolFn.schema.enum(["on", "off", "status"]),
|
||||
},
|
||||
async execute(args: any, _context: any) {
|
||||
// v3.1:返回形状收敛为「纯字符串」(ToolResult = string | {title?, output},
|
||||
// 字符串是最保守、OC 必然能安全处理的形状);整段再包 try/catch 兜底。
|
||||
async execute(args: any, _context: any): Promise<string> {
|
||||
try {
|
||||
const action = String(args?.action || "status")
|
||||
const cfg = readMcpCfg()
|
||||
|
||||
if (cfg.error) {
|
||||
return { title: "MCP 开关失败", output: "失败:" + cfg.error }
|
||||
return "MCP 开关失败:失败:" + cfg.error
|
||||
}
|
||||
|
||||
const hasMcp = activeHasMcp(cfg.activePath)
|
||||
const modeTxt = modeLabel(cfg.mode)
|
||||
|
||||
if (action === "status") {
|
||||
return {
|
||||
title: "MCP 当前状态",
|
||||
output: [
|
||||
`模式:${modeTxt}(mcpcfg.txt「模式」键)`,
|
||||
`MCP:${hasMcp ? "已开启(配置含 mcp.servers)" : "已关闭(安静,无 mcp 配置)"}`,
|
||||
`活配置文件:${cfg.activePath}`,
|
||||
`对调目标文件:${cfg.onPath === cfg.activePath ? cfg.quietPath : cfg.onPath}`,
|
||||
cfg.mode === "manual" ? "提示:手动模式下只能由用户执行 /mcpon 或 /mcpoff。" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}
|
||||
return [
|
||||
"MCP 当前状态",
|
||||
`模式:${modeTxt}(mcpcfg.txt「模式」键)`,
|
||||
`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「模式: 关闭」)。如需使用,请让用户把「模式」键改成「自动」。",
|
||||
}
|
||||
return "MCP 开关被禁用:拒绝执行,MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。如需使用,请让用户把「模式」键改成「自动」。"
|
||||
}
|
||||
|
||||
if (cfg.mode === "manual") {
|
||||
return {
|
||||
title: "手动模式拒绝",
|
||||
output:
|
||||
"拒绝执行:当前是手动模式,只有用户能切换 MCP 状态。\n" +
|
||||
"请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" +
|
||||
"如果确实需要大模型自动切换,让用户把 mcpcfg.txt 的「模式」键改成「自动」。",
|
||||
}
|
||||
return (
|
||||
"手动模式拒绝:当前是手动模式,只有用户能切换 MCP 状态。\n" +
|
||||
"请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" +
|
||||
"如果确实需要大模型自动切换,让用户把 mcpcfg.txt 的「模式」键改成「自动」。"
|
||||
)
|
||||
}
|
||||
|
||||
// auto 模式:执行切换
|
||||
@@ -573,19 +583,24 @@ async function buildHooks(ctx: any) {
|
||||
|
||||
if (!res.ok) {
|
||||
await slog(ctx?.client, "error", "mcp_switch 失败: " + res.why)
|
||||
return { title: "MCP 切换失败", output: "失败:" + res.why }
|
||||
return "MCP 切换失败:失败:" + res.why
|
||||
}
|
||||
if (res.unchanged) {
|
||||
return { title: "MCP 状态未变", output: "无需切换。" + res.why }
|
||||
return "MCP 状态未变:无需切换。" + res.why
|
||||
}
|
||||
const reloadTxt = res.reloaded
|
||||
? res.pushed
|
||||
? "mcp 配置已先推送进 OC,再完成对调 + 热重载(双保险),MCP 百分百加载。"
|
||||
: "配置已对调并完成热重载,MCP 配置立即生效。(推送兜底未生效,已走 reload 单通道)"
|
||||
: "配置已对调,但热重载未成功:" + (res.why || "") + "\n需要重启 OpenCode 才能生效。"
|
||||
return {
|
||||
title: "MCP 已切换",
|
||||
output: `已把 MCP 切换为「${res.state}」状态。\n${reloadTxt}`,
|
||||
return `MCP 已切换:已把 MCP 切换为「${res.state}」状态。\n${reloadTxt}`
|
||||
} catch (e: any) {
|
||||
// 最后一道防线:绝不把异常抛回 OC 的工具执行链路
|
||||
const msg = e && e.message ? e.message : String(e)
|
||||
try {
|
||||
await slog(ctx?.client, "error", "mcp_switch 执行异常(已降级为纯字符串返回): " + msg)
|
||||
} catch {}
|
||||
return "MCP 开关执行异常(已兜底):" + msg
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -593,19 +608,46 @@ 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 v3 server 插件已加载(命名导出,推送走 GET+merge 补 shell)")
|
||||
return await buildHooks(ctx)
|
||||
}
|
||||
// ---------------------------------------------------------------- 双导出(v3.1,五次写盘实验定稿)
|
||||
// default 是 schema 唯一接受的形状:{ id: string, setup: fn } 或 { id: string, effect: fn },
|
||||
// hooks 的真正注册点是 default.setup —— 命名导出【不承担注册职责】,它只是必须存在的占位。
|
||||
//
|
||||
// 2026-09-23 对 OC 2.0.15 做五组写盘对照(每组固定触发 10 次 watcher 批量 reload,
|
||||
// 全崩或全稳,5/5 一致;崩溃 cause 均为 TypeError: Ae is not a function,
|
||||
// 'Ae' is an instance of Object —— 加载链在 setup 之前就把某个 Object 当函数调):
|
||||
// ① 无命名导出 → 崩 ×10,setup 永不执行
|
||||
// ② body=(await({} as any), undefined as any) → 稳,setup ×10 全成功(实验 A)
|
||||
// ③ body=await buildHooks(ctx)(返回 hooks 对象)→ 崩 ×10
|
||||
// ④ 同② body 再次写盘 → 稳,setup ×10 全成功(实验 D,二次复现)
|
||||
// ⑤ body=undefined 直返 → 崩 ×10(推翻「返回 undefined 即安全」的猜测)
|
||||
// 结论:body 必须保持 ②/④ 验证过的【逗号表达式原文】——看似等价的直返 undefined(⑤)
|
||||
// 和真实调用(③)实测都会炸,机制层面无法静态还原(fromPromise/a8 已深度内联)。
|
||||
// 改动此行前必读上面的实验矩阵,改完必须写盘观察 failed Δ+0 且 setup 增长。
|
||||
export const AiMcp: any = async (ctx: any) => (await ({} as any), undefined as any)
|
||||
|
||||
// 2) default 导出:v2 校验形式({ id, setup }),与命名导出返回同一套 hooks,
|
||||
// 无论加载器认哪一种,mcp_switch 都能注册成功(同名工具后者覆盖,行为一致)。
|
||||
export default {
|
||||
id: "ai-mcp",
|
||||
async setup(ctx: any) {
|
||||
await slog(ctx?.client, "info", "ai-mcp v3 server 插件已加载(default/setup,推送走 GET+merge 补 shell)")
|
||||
return await buildHooks(ctx)
|
||||
async setup(ctx: any): Promise<any> {
|
||||
// setup 是 OC activate 阶段的调用点:任何抛出都会变成
|
||||
// "failed to reload plugins",把整批插件重载一起打挂 —— 这里必须绝对不抛。
|
||||
try {
|
||||
const hooks = await buildHooks(ctx)
|
||||
const keys = hooks && typeof hooks === "object" ? Object.keys(hooks) : []
|
||||
const toolNames = hooks && hooks.tool ? Object.keys(hooks.tool) : []
|
||||
// 日志挪到 hooks 构建成功之后:先打「已加载」再构建 = 假绿灯
|
||||
await slog(
|
||||
ctx?.client,
|
||||
"info",
|
||||
`ai-mcp v3.1 setup 完成:hooks=[${keys.join(",")}] tools=[${toolNames.join(",")}]` +
|
||||
(toolNames.length ? "(mcp_switch 注册成功)" : "(警告:未注册任何 tool)"),
|
||||
)
|
||||
return hooks
|
||||
} catch (e: any) {
|
||||
const msg = e && e.message ? e.message : String(e)
|
||||
try {
|
||||
await slog(ctx?.client, "error", "ai-mcp setup 异常(已吞掉,返回空 hooks): " + msg)
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user