feat: MCP 端点鉴权改用后台 Token 管理,废弃 mcp_kv 配置
build-and-deploy / build-and-deploy (push) Successful in 42s

- tokenService 支持 mcpEnabled 标记,新增 getMcpTokenValues 与迁移逻辑
- /mcp 鉴权改为校验 Token 表中标记为 MCP 访问的生效令牌
- 启动时自动把旧 mcp_token 配置迁成一条 MCP Token,保证旧客户端不中断
- admin 页面 Token 表新增 MCP 访问列与生成/编辑开关
- 新增 tokenService 测试(58 项全部通过)
This commit is contained in:
jif2.zhang
2026-09-20 21:45:18 +08:00
parent 26957bcb49
commit be2e4d9cdf
5 changed files with 169 additions and 21 deletions
+16 -3
View File
@@ -117,7 +117,7 @@
<table class="data-table" id="token-table">
<thead>
<tr>
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
<th>名称</th><th>关联项目</th><th>权限</th><th>MCP 访问</th><th>Token</th>
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
@@ -229,6 +229,12 @@
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="input-token-mcp" style="width:16px;height:16px">
<span>作为 MCP 访问令牌(用于 /mcp 端点鉴权)</span>
</label>
</div>
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
</div>
<div class="modal-footer">
@@ -486,7 +492,7 @@
function renderTokens(tokens) {
var tbody = document.getElementById("token-tbody");
if (!tokens.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
return;
}
tbody.innerHTML = tokens.map(function (t) {
@@ -499,6 +505,9 @@
"<td>" + esc(t.name) + "</td>" +
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
'<td>' + (t.mcpEnabled
? '<span class="dim-chip" style="color:#0a7a3c;border-color:#0a7a3c">MCP</span>'
: '<span style="color:#aaa">—</span>') + "</td>" +
'<td><div class="token-reveal">' +
'<span class="token-value token-mask" data-id="' + t.id + '">' + masked + "</span>" +
'<button class="token-btn" onclick="toggleTokenVis(this)">显示</button>' +
@@ -508,7 +517,7 @@
"<td>" + fmtDate(t.expiresAt) + "</td>" +
"<td>" + fmtDate(t.createdAt) + "</td>" +
'<td style="white-space:nowrap">' +
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission }).replace(/'/g, "&#39;") + ")'>编辑</button> " +
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission, mcpEnabled: !!t.mcpEnabled }).replace(/'/g, "&#39;") + ")'>编辑</button> " +
(t.enabled
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
: "") +
@@ -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");
+19 -13
View File
@@ -1,7 +1,7 @@
/**
* MCP HTTP 传输层
* 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。
* 鉴权:基于 data/mcp.json 中配置的 token,要求请求头 Authorization: Bearer <token>。
* 鉴权:基于后台 Token 管理中标记为「MCP 访问」的令牌,要求请求头 Authorization: Bearer <token>。
*/
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<boolean>} 是否鉴权通过
*/
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;
}
/**
+4 -1
View File
@@ -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}`);
+50 -4
View File
@@ -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<Object>} 创建的 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<Object>}
*/
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<string[]>} 生效的 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<void>}
*/
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,
};
+80
View File
@@ -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, "");
});