server插件v3:推送改GET+merge补shell走/api/experimental/config,口令链读service.json+Basic opencode

This commit is contained in:
ypshidifu
2026-09-23 17:50:24 +08:00
parent a25683591e
commit abb74ab20d
2 changed files with 103 additions and 20 deletions
+15 -6
View File
@@ -70,12 +70,20 @@ C:\Users\<你>\.config\opencode\plugins\ai-mcp.ts # Windows
| 手动 | ❌ 拒绝,提示用户用 `/mcpon` `/mcpoff` | ✅ 仅此可用 |
| 关闭 | ❌ 拒绝 | ❌ 不注册 |
**开启方向是「推送 + reload」双保险(v2 新增)**:对调并写好 `mcp.on.flag` 之后、
reload 之前,先把活配置里的 `mcp` 推进**运行中的 OC** —— 三连尝试:
`client.config.update({ config: mcp })`(SDK → `PATCH /config`,**无 `/api` 前缀**)
→ `({ body: { mcp } })` → 原生 `fetch PATCH /config`。
**开启方向是「推送 + reload」双保险(v3 改为 GET+merge 补 shell)**:对调并写好
`mcp.on.flag` 之后、reload 之前,先把活配置里的 `mcp` 推进**运行中的 OC**:
1. `GET /api/config?directory=` 取运行中配置的 `info.shell`(GET → 活文件 → `null`
三级兜底;`PATCH` body **必含 `shell`**,缺则 400 `Missing key at ["shell"]`,
裸 `PATCH /config` 是 405,必须走 `/api/experimental/config`);
2. 口令链 `service.json` → 环境变量 `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD`
→ `opencode.json server.password`,鉴权 `Basic base64("opencode:<口令>")`;
3. SDK 两跳 `config.update({ shell, mcp })` / `({ body })` → 兜底原生
`fetch PATCH /api/experimental/config?directory=`。
推送成功再 reload,MCP **百分百加载**;推送失败只记日志,自动降级为原 reload 单通道,
不影响后续流程。TUI 插件 `/mcpon`(v9)与 CLI `mcpctl on` 是同一套链。
不影响后续流程。TUI 插件 `/mcpon`(v10)与 CLI `mcpctl on` 是同一套语义
(GET+merge 补 shell → PATCH 真实路由),TUI 走进程内三跳、CLI 走端口发现+探活。
reload 走三连尝试:`client.location.reload({location})` → `({})` → 无参 →
兜底 `POST /api/location/reload`。全部失败时配置**已经对调完成**(若推送已成功则
@@ -115,7 +123,8 @@ Plugin must export a default definition with an id and an effect or setup functi
| 日志内容 | 含义 |
| --- | --- |
| `module imported (pid=...)` | 文件**被 OpenCode import 了**(模块级留痕,最先出现) |
| `ai-mcp server 插件已加载(命名导出/default/setup)` | 插件函数**被调用了**(session 初始化时触发) |
| `ai-mcp v3 server 插件已加载(命名导出,推送走 GET+merge 补 shell)` | 命名导出**被调用了**(legacy loader 入口) |
| `ai-mcp v3 server 插件已加载(default/setup,推送走 GET+merge 补 shell)` | default 导出**被调用了**(V2 校验入口) |
| 什么都没有 | **没被加载** → 检查 `plugins` 数组是否注册、路径是否写对 |
| `无法加载 @opencode-ai/plugin` | 依赖没装,去插件目录 `npm install` |
+88 -14
View File
@@ -287,10 +287,51 @@ async function doReload(client: any, serverUrl: any) {
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 ""
}
}
// ---------------------------------------------------------------- P2 兜底:reload 前先把 mcp 配置送进 OC
// 对调只是改了磁盘,reload 读盘时序偶发不稳会导致 MCP 没加载上。
// 这里在 reload 之前先 PATCH /config 把 mcp 配置推进运行中的 OC,
// 「配置就位」不再依赖 reload —— 推送 + reload 双保险,百分百加载。
// v3 改为 GET+merge:PATCH 的 zod schema 里 shell 是必填键(string|null),
// 只送 mcp 会 400「Missing key at ["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 {
@@ -300,17 +341,33 @@ async function pushMcpConfig(ctx: any, activePath: string | null): Promise<boole
if (!mcp || !mcp.servers || !Object.keys(mcp.servers).length) return false
const errs: string[] = []
const c = ctx?.client
if (c && c.config && typeof c.config.update === "function") {
// SDK 签名:parameters.config -> body(PATCH /config,注意无 /api 前缀)
// 1) GET 当前 config → 取 shell 的权威值
let base: any = null
if (c && c.config && typeof c.config.get === "function") {
try {
await c.config.update({ config: mcp })
await slog(c, "info", "mcp 配置已推送进 OC(client.config.update → PATCH /config)")
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(c, "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: { mcp } })
await c.config.update({ body: payload })
await slog(c, "info", "mcp 配置已推送进 OC(body 变体)")
return true
} catch (e: any) {
@@ -319,18 +376,35 @@ async function pushMcpConfig(ctx: any, activePath: string | null): Promise<boole
} else {
errs.push("client.config.update 不存在")
}
// raw HTTP 兜底:真实路由是 PATCH /api/experimental/config(/config 会 405)
if (ctx?.serverUrl) {
try {
const res = await fetch(new URL("/config", ctx.serverUrl), {
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: { "content-type": "application/json" },
body: JSON.stringify({ mcp }),
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ shell: rawShell, mcp }),
})
if (res.ok) {
await slog(c, "info", "mcp 配置已推送进 OC(HTTP PATCH /config)")
await slog(c, "info", "mcp 配置已推送进 OC(HTTP PATCH /api/experimental/config)")
return true
}
errs.push("http PATCH /config -> " + res.status)
errs.push(`http PATCH /api/experimental/config -> ${res.status}`)
} catch (e: any) {
errs.push("http: " + e.message)
}
@@ -522,7 +596,7 @@ 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 v2 server 插件已加载(命名导出,含推送兜底)")
await slog(ctx?.client, "info", "ai-mcp v3 server 插件已加载(命名导出,推送走 GET+merge 补 shell)")
return await buildHooks(ctx)
}
@@ -531,7 +605,7 @@ export const AiMcp: Plugin = async (ctx) => {
export default {
id: "ai-mcp",
async setup(ctx: any) {
await slog(ctx?.client, "info", "ai-mcp v2 server 插件已加载(default/setup,含推送兜底)")
await slog(ctx?.client, "info", "ai-mcp v3 server 插件已加载(default/setup,推送走 GET+merge 补 shell)")
return await buildHooks(ctx)
},
}