registry/src/mcp/index.js
cheney 963046edd9
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 39s
feat: 新增 MCP 服务,支持 AI 自动操作项目与配置
- Streamable HTTP 传输,/mcp 端点,Bearer token 鉴权(data/mcp.json,已忽略)
- 项目/配置完整增删改查工具,删除为两步式确认(delete_* + confirm_delete_*)
- 写操作遵循系统模式,readonly 下拒绝
- 依赖 @modelcontextprotocol/sdk,新增单元测试
2026-09-08 10:03:09 +08:00

114 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MCP HTTP 传输层
* 以 Streamable HTTP 协议在 /mcp 端点暴露 MCP 服务。
* 鉴权:基于 data/mcp.json 中配置的 token要求请求头 Authorization: Bearer <token>。
*/
const crypto = require("crypto");
const express = require("express");
const { randomUUID } = require("node:crypto");
const {
StreamableHTTPServerTransport,
} = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js");
const { createMcpServer } = require("./tools");
const { loadMcpConfig } = require("../persistence/jsonAdapter");
const router = express.Router();
// sessionId -> transport用于会话复用
const transports = new Map();
/**
* 校验请求携带的 Bearer token 是否与配置中的 token 匹配
* @param {string} [header] - Authorization 请求头
* @returns {boolean} 是否鉴权通过
*/
function isAuthorized(header) {
const mcp = loadMcpConfig();
// 未配置 token 时视为未启用鉴权
if (!mcp.token) {
return true;
}
const expected = mcp.token;
const provided = header && header.startsWith("Bearer ") ? header.slice(7).trim() : "";
if (!provided || provided.length !== expected.length) {
return false;
}
// 使用常量时间比较,避免时序侧信道
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return crypto.timingSafeEqual(a, b);
}
/**
* 鉴权中间件token 校验失败返回 401
*/
router.use((req, res, next) => {
if (!isAuthorized(req.headers.authorization)) {
return res
.status(401)
.json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: invalid or missing token" }, id: null });
}
next();
});
/**
* 根据会话 ID 或其下的 transport 处理请求
* 若带 mcp-session-id 且存在则复用;否则仅初始化请求可新建会话
*/
async function handleRequest(req, res) {
const sessionId = req.headers["mcp-session-id"];
const jsonBody = req.body;
try {
let transport;
if (sessionId && transports.get(sessionId)) {
transport = transports.get(sessionId);
} else if (!sessionId && isInitializeRequest(jsonBody)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports.set(sid, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) {
transports.delete(transport.sessionId);
}
};
const server = createMcpServer();
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: missing or invalid mcp-session-id" },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP 请求处理失败:", err);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
}
// POST /mcp初始化与工具调用
router.post("/", (req, res) => handleRequest(req, res));
// GET /mcp建立 SSE 流(会话恢复/服务器通知)
router.get("/", (req, res) => handleRequest(req, res));
// DELETE /mcp会话终止
router.delete("/", (req, res) => handleRequest(req, res));
module.exports = router;