From be2e4d9cdf55108c0f2b539d559f18ed46b9866a Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 20 Sep 2026 21:45:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MCP=20=E7=AB=AF=E7=82=B9=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E6=94=B9=E7=94=A8=E5=90=8E=E5=8F=B0=20Token=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E5=BA=9F=E5=BC=83=20mcp=5Fkv=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tokenService 支持 mcpEnabled 标记,新增 getMcpTokenValues 与迁移逻辑 - /mcp 鉴权改为校验 Token 表中标记为 MCP 访问的生效令牌 - 启动时自动把旧 mcp_token 配置迁成一条 MCP Token,保证旧客户端不中断 - admin 页面 Token 表新增 MCP 访问列与生成/编辑开关 - 新增 tokenService 测试(58 项全部通过) --- public/admin.html | 19 +++++++-- src/mcp/index.js | 32 +++++++++------ src/server.js | 5 ++- src/services/tokenService.js | 54 ++++++++++++++++++++++-- test/tokenService.test.js | 80 ++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 test/tokenService.test.js diff --git a/public/admin.html b/public/admin.html index 5714f03..5ab01fc 100644 --- a/public/admin.html +++ b/public/admin.html @@ -117,7 +117,7 @@ - + @@ -229,6 +229,12 @@ +
+ +

生成后 Token 明文仅展示一次,请及时复制并妥善保管。

'; + tbody.innerHTML = ''; return; } tbody.innerHTML = tokens.map(function (t) { @@ -499,6 +505,9 @@ "" + "" + '" + + '" + '" + "" + '
名称关联项目权限Token名称关联项目权限MCP 访问Token 状态过期时间创建时间操作
暂无 Token
暂无 Token
" + esc(t.name) + "" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "' + (t.permission === "readwrite" ? "读写" : "只读") + "' + (t.mcpEnabled + ? 'MCP' + : '—') + "
' + '' + masked + "" + '' + @@ -508,7 +517,7 @@ "
" + fmtDate(t.expiresAt) + "" + fmtDate(t.createdAt) + "' + - ' " + + ' " + (t.enabled ? '" : "") + @@ -559,6 +568,7 @@ document.getElementById("input-token-project").value = "all"; document.getElementById("input-token-permission").value = "readonly"; document.getElementById("input-token-expiry").value = "90"; + document.getElementById("input-token-mcp").checked = false; document.getElementById("btn-save-token").textContent = "生成"; loadProjectsForSelect(); openModal("modal-token"); @@ -570,6 +580,7 @@ document.getElementById("input-token-name").value = t.name; document.getElementById("input-token-project").value = t.projectId; document.getElementById("input-token-permission").value = t.permission; + document.getElementById("input-token-mcp").checked = !!(t.mcpEnabled); document.getElementById("btn-save-token").textContent = "保存"; loadProjectsForSelect(); openModal("modal-token"); @@ -585,6 +596,7 @@ name: name, projectId: document.getElementById("input-token-project").value, permission: document.getElementById("input-token-permission").value, + mcpEnabled: document.getElementById("input-token-mcp").checked, }); closeModal("modal-token"); toast("Token 已更新", "success"); @@ -594,6 +606,7 @@ name: name, projectId: document.getElementById("input-token-project").value, permission: document.getElementById("input-token-permission").value, + mcpEnabled: document.getElementById("input-token-mcp").checked, expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0, }); closeModal("modal-token"); diff --git a/src/mcp/index.js b/src/mcp/index.js index cfb2b5b..bc68e9f 100644 --- a/src/mcp/index.js +++ b/src/mcp/index.js @@ -1,7 +1,7 @@ /** * MCP HTTP 传输层 * 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。 - * 鉴权:基于 data/mcp.json 中配置的 token,要求请求头 Authorization: Bearer 。 + * 鉴权:基于后台 Token 管理中标记为「MCP 访问」的令牌,要求请求头 Authorization: Bearer 。 */ const crypto = require("crypto"); const express = require("express"); @@ -11,7 +11,7 @@ const { } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js"); const { createMcpServer } = require("./tools"); -const { loadMcpConfig } = require("../persistence/duckdbAdapter"); +const { getMcpTokenValues } = require("../services/tokenService"); const router = express.Router(); @@ -19,25 +19,31 @@ const router = express.Router(); const transports = new Map(); /** - * 校验请求携带的 Bearer token 是否与配置中的 token 匹配 + * 校验请求携带的 Bearer token 是否命中任意一个生效的 MCP 访问令牌 * @param {string} [header] - Authorization 请求头 * @returns {Promise} 是否鉴权通过 */ async function isAuthorized(header) { - const mcp = await loadMcpConfig(); - // 未配置 token 时视为未启用鉴权 - if (!mcp.token) { + const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + const expectedList = await getMcpTokenValues(); + // 未配置 MCP 令牌时视为未启用鉴权 + if (expectedList.length === 0) { return true; } - const expected = mcp.token; - const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : ""; - if (!provided || provided.length !== expected.length) { + if (!provided) { return false; } - // 使用常量时间比较,避免时序侧信道 - const a = Buffer.from(provided); - const b = Buffer.from(expected); - return crypto.timingSafeEqual(a, b); + // 与任一生效令牌做常量时间比较,避免时序侧信道 + for (const expected of expectedList) { + if (provided.length === expected.length) { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (crypto.timingSafeEqual(a, b)) { + return true; + } + } + } + return false; } /** diff --git a/src/server.js b/src/server.js index 73399c8..264e52a 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ const tokenRoutes = require("./routes/tokens"); const { loadSystem, saveSystem } = require("./persistence/duckdbAdapter"); const duckdbAdapter = require("./persistence/duckdbAdapter"); const userService = require("./services/userService"); +const tokenService = require("./services/tokenService"); const versionInfo = require("./version"); // MCP 服务(Model Context Protocol,供 AI 自动操作) @@ -95,10 +96,12 @@ app.put("/api/mode", async (req, res) => { }); /** - * 启动服务:先初始化 DuckDB(自动迁移 JSON 存量数据),再创建默认管理员账号 + * 启动服务:先初始化 DuckDB(自动迁移 JSON 存量数据), + * 迁移历史 MCP token 至 Token 表,再创建默认管理员账号 */ async function start() { await duckdbAdapter.init(); + await tokenService.migrateLegacyMcpToken(); await userService.ensureDefaultAdmin(); app.listen(PORT, () => { console.log(`Registry 服务已启动: http://localhost:${PORT}`); diff --git a/src/services/tokenService.js b/src/services/tokenService.js index e0bb610..179baa7 100644 --- a/src/services/tokenService.js +++ b/src/services/tokenService.js @@ -1,9 +1,9 @@ /** * API Token 服务 * 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json - * (与 MCP token 独立,MCP token 由 data/mcp.json 管理) + * 支持 mcpEnabled 标记:标记后该 token 用于 MCP /mcp 端点鉴权(取代旧的 mcp.json / mcp_kv) */ -const { loadApiTokens, saveApiTokens } = require("../persistence/duckdbAdapter"); +const { loadApiTokens, saveApiTokens, loadMcpConfig, saveMcpConfig } = require("../persistence/duckdbAdapter"); const { generateToken } = require("./userService"); /** @@ -25,7 +25,7 @@ async function findById(id) { /** * 创建 Token - * @param {Object} data - { name, projectId, permission, expiresInDays } + * @param {Object} data - { name, projectId, permission, mcpEnabled, expiresInDays } * @returns {Promise} 创建的 Token(含明文 value) */ async function createToken(data) { @@ -44,6 +44,7 @@ async function createToken(data) { name: data.name.trim(), projectId: data.projectId || "all", permission: data.permission === "readwrite" ? "readwrite" : "readonly", + mcpEnabled: !!data.mcpEnabled, value: tokenValue, enabled: true, createdAt: now.toISOString(), @@ -57,7 +58,7 @@ async function createToken(data) { /** * 更新 Token 元信息 * @param {string} id - * @param {Object} data - { name, projectId, permission, enabled } + * @param {Object} data - { name, projectId, permission, mcpEnabled, enabled } * @returns {Promise} */ async function updateToken(id, data) { @@ -67,6 +68,7 @@ async function updateToken(id, data) { if (data.name !== undefined) tokens[idx].name = data.name.trim(); if (data.projectId !== undefined) tokens[idx].projectId = data.projectId; if (data.permission !== undefined) tokens[idx].permission = data.permission; + if (data.mcpEnabled !== undefined) tokens[idx].mcpEnabled = !!data.mcpEnabled; if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled; await saveApiTokens(tokens); return tokens[idx]; @@ -113,6 +115,48 @@ async function validateToken(value) { return token; } +/** + * 获取启用了 MCP 访问的生效 Token 值列表(用于 /mcp 端点鉴权) + * @returns {Promise} 生效的 MCP token 明文值 + */ +async function getMcpTokenValues() { + const now = Date.now(); + return (await loadApiTokens()) + .filter((t) => t.mcpEnabled && t.enabled && (!t.expiresAt || new Date(t.expiresAt).getTime() > now)) + .map((t) => t.value); +} + +/** + * 一次性迁移历史 MCP 鉴权配置(旧 mcp.json / mcp_kv)到 Token 表 + * 兼容线上已有用旧 token 连接的 MCP 客户端:把旧 token 转为一条启用 mcpEnabled 的 Token,并清除旧配置 + * @returns {Promise} + */ +async function migrateLegacyMcpToken() { + const tokens = await loadApiTokens(); + if (tokens.some((t) => t.mcpEnabled)) { + return; + } + const mcp = await loadMcpConfig(); + if (!mcp || !mcp.token) { + return; + } + const now = new Date(); + tokens.push({ + id: generateToken(8), + name: "MCP(历史迁移)", + projectId: "all", + permission: "readwrite", + mcpEnabled: true, + value: mcp.token, + enabled: true, + createdAt: now.toISOString(), + expiresAt: null, + }); + await saveApiTokens(tokens); + await saveMcpConfig({ enabled: false, token: "" }); + console.log("[tokenService] 已迁移历史 MCP token 至 Token 表,旧 mcp_kv 配置已清空"); +} + module.exports = { getAllTokens, findById, @@ -121,4 +165,6 @@ module.exports = { revokeToken, deleteToken, validateToken, + getMcpTokenValues, + migrateLegacyMcpToken, }; diff --git a/test/tokenService.test.js b/test/tokenService.test.js new file mode 100644 index 0000000..887bc25 --- /dev/null +++ b/test/tokenService.test.js @@ -0,0 +1,80 @@ +/** + * Token 服务单元测试 + * 覆盖:mcpEnabled 标记、MCP token 列表过滤、历史 MCP 配置迁移 + * 运行:npm test + */ +const test = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +// 使用临时数据目录,避免污染真实 data/ +const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-token-test-")); +process.env.REGISTRY_DATA_DIR = tmpDataDir; + +const { init } = require("../src/persistence/duckdbAdapter"); +const tokenService = require("../src/services/tokenService"); + +test.before(async () => { + await init(); +}); + +test("createToken 保存 mcpEnabled 标记", async () => { + const mcpToken = await tokenService.createToken({ + name: "mcp-key", + projectId: "all", + permission: "readwrite", + mcpEnabled: true, + expiresInDays: 30, + }); + assert.strictEqual(mcpToken.mcpEnabled, true); + const plainToken = await tokenService.createToken({ + name: "plain", + mcpEnabled: false, + }); + assert.strictEqual(plainToken.mcpEnabled, false); +}); + +test("getMcpTokenValues 仅返回生效的 MCP token 明文", async () => { + const values = await tokenService.getMcpTokenValues(); + assert.ok(values.length >= 1); + // 明文值以 reg_ 开头 + assert.ok(values.every((v) => typeof v === "string" && v.startsWith("reg_"))); +}); + +test("updateToken 可切换 mcpEnabled", async () => { + const plain = await tokenService.createToken({ name: "toggle", mcpEnabled: false }); + const before = await tokenService.getMcpTokenValues(); + assert.ok(!before.includes(plain.value)); + await tokenService.updateToken(plain.id, { mcpEnabled: true }); + const after = await tokenService.getMcpTokenValues(); + assert.ok(after.includes(plain.value)); +}); + +test("吊销或过期后 MCP token 不再生效", async () => { + const t = await tokenService.createToken({ name: "will-revoke", mcpEnabled: true, expiresInDays: 30 }); + const ok = await tokenService.getMcpTokenValues(); + assert.ok(ok.includes(t.value)); + await tokenService.revokeToken(t.id); + const revoked = await tokenService.getMcpTokenValues(); + assert.ok(!revoked.includes(t.value)); +}); + +test("migrateLegacyMcpToken 把历史 mcp_kv 配置迁移为 Token 记录", async () => { + // 清理当前 token 表,模拟空 MCP 标记状态 + const { loadApiTokens, saveApiTokens, saveMcpConfig, loadMcpConfig } = require("../src/persistence/duckdbAdapter"); + await saveApiTokens([]); + await saveMcpConfig({ enabled: true, token: "legacy-mcp-token-abc" }); + await tokenService.migrateLegacyMcpToken(); + + const tokens = await loadApiTokens(); + const migrated = tokens.find((t) => t.mcpEnabled); + assert.ok(migrated, "应生成一条 mcpEnabled 的 Token"); + assert.strictEqual(migrated.value, "legacy-mcp-token-abc"); + const values = await tokenService.getMcpTokenValues(); + assert.ok(values.includes("legacy-mcp-token-abc")); + // 旧配置已清空 + const mcp = await loadMcpConfig(); + assert.strictEqual(mcp.token, ""); +}); \ No newline at end of file