server插件v3:推送改GET+merge补shell走/api/experimental/config,口令链读service.json+Basic opencode
This commit is contained in:
@@ -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` |
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user