单实例多 MCP 聚合服务:两种实现方案深度对比
MCP
Nginx
Express
Model Context Protocol(MCP)是连接 AI 应用与外部工具、数据服务的标准协议。当需要同时运行多个 MCP 服务时,「一服务一端口」的传统部署方式会迅速带来端口管理、资源占用与运维复杂度等问题。本文对比两种单实例多 MCP 聚合实现:方案 1 用 Nginx 做反向代理(honeycomb-nginx),方案 2 用 Express 在单端口内路由分发(honeycomb)。
背景
MCP 协议速览
MCP(Model Context Protocol)由 Anthropic 提出,用于规范 AI 应用与外部工具、数据之间的通信。常见传输方式包括 HTTP Stream、SSE(Server-Sent Events)和 WebSocket;核心能力则围绕三类对象展开:
- Tools:可被 AI 调用的功能单元,含名称、描述与输入/输出 Schema
- Resources:可被 AI 读取的数据资源
- Prompts:预定义的提示模板
什么是「单实例多 MCP 聚合」
所谓单实例多 MCP 聚合,是指在统一入口下同时承载多个 MCP 服务,客户端通过 Header 等标识符选择目标服务,而不是为每个 MCP 单独暴露地址与端口。
这样做的好处很直接:资源占用更低、配置与日志集中管理、部署面更单一,也便于按需增删服务。两种方案都遵循这一思路,差异在于隔离粒度与运维模型。
两种方案一览
方案一:Nginx 反向代理
架构设计
方案 1 将 Nginx 作为统一入口,各 MCP 服务仍监听独立端口,由代理按 Header 转发:
客户端请求
↓
Nginx(端口 80)
↓ 读取 X-Target-Port
后端 MCP 服务
├── 服务 1(8080)
├── 服务 2(8081)
├── 服务 3(8082)
└── 服务 4(8083)
请求链路如下:
- 客户端访问 Nginx(默认 80 端口),在 Header 中带上
X-Target-Port
- Nginx 根据该 Header 将流量转发到对应后端端口
- 目标 MCP 服务处理请求并返回响应(含 SSE 流)
环境准备
- Node.js >= 22.0.0
- pnpm >= 8.0.0
- Nginx >= 1.21
- Docker(可选,用于容器化部署)
主要依赖:
{
"dependencies": {
"fastmcp": "^0.1.0"
}
}
实现
模块 1:MCP 服务启动
从 config.json 读取服务列表,为每个条目启动独立的 FastMCP 实例,并绑定不同端口。
核心代码(src/index.ts):
import config from "../config.json";
import { FastMCP } from "fastmcp";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const start = () => {
config.forEach(async (mcpConfig) => {
// 创建 FastMCP 服务器实例
const server = new FastMCP({
name: mcpConfig.name,
version: mcpConfig.version as `${number}.${number}.${number}`,
});
// 动态导入服务模块并注册工具
const { default: registerTools } = await import(
path.join(__dirname, mcpConfig.entry.replace(".ts", ".mjs"))
);
registerTools(server);
// 启动服务,监听指定端口
server.start({
transportType: "httpStream",
httpStream: {
host: "0.0.0.0",
port: mcpConfig.port, // 每个服务独立端口
},
});
});
};
start();
要点:
FastMCP 负责 MCP 协议与 HTTP Stream 传输
config.json 驱动服务发现,入口模块通过动态 import() 加载
- 每个实例独占端口,进程级物理隔离
配置文件示例(config.json):
[
{
"name": "Common MCP Server",
"version": "1.0.0",
"entry": "servers/_common/index.ts",
"port": 8080
},
{
"name": "Temperature Conversion MCP Server",
"version": "1.0.0",
"entry": "servers/temperatureConversion/index.ts",
"port": 8081
}
]
模块 2:Nginx 反向代理
用 map 将 X-Target-Port 映射为上游地址,并针对 SSE 关闭缓冲、拉长超时。
核心配置(docker/nginx.conf):
http {
# 使用 map 指令根据 X-Target-Port header 动态构建后端地址
map $http_x_target_port $backend_upstream {
default "http://0.0.0.0:8080"; # 默认端口
"~^(\d+)$" "http://0.0.0.0:$1"; # 如果 X-Target-Port 是数字,使用该端口
}
server {
listen 80;
server_name _;
location / {
# 使用动态构建的后端地址
proxy_pass $backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE (Server-Sent Events) 配置
proxy_http_version 1.1;
proxy_buffering off; # 关闭缓冲,支持流式传输
proxy_cache off;
proxy_set_header Connection "keep-alive";
proxy_read_timeout 86400s; # 长连接超时时间
proxy_send_timeout 60s;
}
}
}
说明:
$http_x_target_port 对应请求头 X-Target-Port(Nginx 会把 - 转为 _)
proxy_pass 使用变量实现按端口动态转发
proxy_buffering off 与长 proxy_read_timeout 是 SSE 稳定运行的关键
验证命令:
# 测试服务1(端口8080)
curl -H "X-Target-Port: 8080" http://localhost/sse
# 测试服务2(端口8081)
curl -H "X-Target-Port: 8081" http://localhost/sse
模块 3:工具注册
各服务模块自行向 FastMCP 实例注册 Tools。以下以温度转换为例(servers/temperatureConversion/index.ts):
import { FastMCP } from "fastmcp";
import { z } from "zod";
export default function registerTools(server: FastMCP) {
// 注册温度转换工具
server.tool(
"convert_temperature",
"将温度在不同单位之间转换(摄氏度、华氏度、开尔文)",
{
temperature: z.number().describe("要转换的温度值"),
from: z.enum(["celsius", "fahrenheit", "kelvin"]).describe("源温度单位"),
to: z.enum(["celsius", "fahrenheit", "kelvin"]).describe("目标温度单位"),
},
async ({ temperature, from, to }) => {
// 转换逻辑
let celsius = 0;
if (from === "celsius") celsius = temperature;
else if (from === "fahrenheit") celsius = ((temperature - 32) * 5) / 9;
else if (from === "kelvin") celsius = temperature - 273.15;
let result = 0;
if (to === "celsius") result = celsius;
else if (to === "fahrenheit") result = (celsius * 9) / 5 + 32;
else if (to === "kelvin") result = celsius + 273.15;
return {
content: [
{
type: "text",
text: `${temperature}°${from} = ${result.toFixed(2)}°${to}`,
},
],
};
},
);
}
注意点
Nginx 返回 502
通常是后端未启动、端口配置错误或 Nginx 无法连通上游。可按以下步骤排查:
# 检查后端服务是否运行
lsof -i:8080
lsof -i:8081
# 检查 Nginx 配置语法
nginx -t
# 查看 Nginx 错误日志
tail -f /var/log/nginx/error.log
SSE 连接频繁断开
多半是 proxy_read_timeout 过短。适当拉长读写超时:
proxy_read_timeout 86400s; # 24小时
proxy_send_timeout 60s;
端口冲突
检查 config.json 中端口是否唯一,并用 lsof -i:端口号 定位占用进程。
方案二:Express 统一服务
架构设计
方案 2 将所有 MCP 服务收拢到单个 Express 进程,用内存中的 Map<configId, McpHandlers> 做路由分发,配置持久化在 SQL.js:
客户端请求
↓
Express(端口 3002)
↓ 读取 MCP_ID
MCP 服务管理器
↓
Map<configId, McpHandlers>
├── 服务 1(ID: 1)
├── 服务 2(ID: 2)
├── 服务 3(ID: 3)
└── 服务 4(ID: 4)
↓
SQL.js(配置持久化)
请求链路如下:
- 客户端访问 Express(3002),Header 携带
MCP_ID
- 路由中间件解析 ID,从内存映射表取出对应 handlers
- 调用 GET/POST handler 完成 MCP 协议交互
- 配置变更写入 SQL.js,可通过 API 热刷新,无需重启进程
环境准备
- Node.js >= 24.11.1
- pnpm >= 10.25.0
- SQL.js(内置,无需独立数据库服务)
主要依赖:
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"express": "^5.0.0",
"express-mcp-handler": "^1.0.0",
"sql.js": "^1.10.0",
"kysely": "^0.27.0"
}
}
实现
模块 1:动态创建 MCP 服务
启动时从数据库加载配置,为 RUNNING 状态的服务创建 McpServer 实例,并写入 handlers 映射表。
核心代码(packages/honeycomb-server/src/mcp.ts):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { sseHandlers } from "express-mcp-handler";
import { z } from "zod";
/**
* 批量创建 MCP 服务并返回 handlers 映射
*/
export async function createMcpServices(): Promise<Map<number, McpHandlers>> {
const databaseClient = await getDatabaseClient();
// 从数据库加载所有配置(包含关联的工具)
const allConfigsWithTools = await databaseClient.getAllConfigsWithTools();
const handlersMap = new Map<number, McpHandlers>();
for (const config of allConfigsWithTools) {
// 只创建状态为 RUNNING 的服务
if (config.status !== StatusEnum.RUNNING) {
continue;
}
// 创建 MCP 服务器实例
const server = new McpServer({
name: config.name,
version: config.version,
description: config.description,
});
// 批量注册工具
config.tools.forEach((tool) => {
// 解析 JSON Schema 并转换为 Zod schema
const inputSchemaObj = JSON.parse(tool.input_schema);
const outputSchemaObj = JSON.parse(tool.output_schema);
const inputSchema = jsonSchemaToZod(inputSchemaObj);
const outputSchema = jsonSchemaToZod(outputSchemaObj);
// 注册工具
server.registerTool(
tool.name,
{
description: tool.description,
inputSchema,
outputSchema,
},
async ({ input }) => {
// 执行工具回调逻辑
// TODO: 实现实际的工具回调
return {
content: [{ type: "text", text: `测试: ${JSON.stringify(input)}` }],
};
},
);
});
// 创建 SSE handlers
const handlers = sseHandlers(() => server, {
onError: (error: Error, sessionId?: string) => {
consola.error(`[SSE][${config.name}] 错误:`, error);
},
});
// 使用配置ID作为key存储handlers
handlersMap.set(config.id!, handlers);
}
return handlersMap;
}
要点:
Map<number, McpHandlers> 维护 configId → handlers 的映射
- 仅
RUNNING 状态的服务会实例化,便于启停控制
express-mcp-handler 封装 SSE 协议细节,降低样板代码
模块 2:按 MCP_ID 路由
中间件解析 Header 中的 MCP_ID(大小写不敏感),命中映射表后分发给对应 handler。
核心代码(packages/honeycomb-server/src/mcp.ts):
/**
* 创建路由处理器(根据 MCP_ID 选择对应的 handler)
*/
export function createMcpRouteHandler(
handlersMap: Map<number, McpHandlers>,
handlerType: "get" | "post",
) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
// 解析 MCP_ID
const mcpIdHeader = req.headers.mcp_id || req.headers.MCP_ID;
const mcpId = mcpIdHeader
? parseInt(typeof mcpIdHeader === "string" ? mcpIdHeader : mcpIdHeader[0], 10)
: null;
if (mcpId === null || Number.isNaN(mcpId)) {
res.status(400).json({
error: "缺少或无效的 MCP_ID header 参数",
message: "请在请求 Header 中添加 MCP_ID 或 mcp_id 参数(数字类型)",
});
return;
}
// 从映射表中获取对应的 handlers
const handlers = handlersMap.get(mcpId);
if (!handlers) {
res.status(404).json({
error: `未找到 ID 为 ${mcpId} 的 MCP 配置`,
message: `请检查 MCP_ID 是否正确,当前可用的 MCP ID: ${Array.from(handlersMap.keys()).join(", ")}`,
});
return;
}
// 调用对应的 handler(GET 或 POST)
const targetHandler = handlerType === "get" ? handlers.getHandler : handlers.postHandler;
targetHandler(req, res, next);
};
}
路由注册(packages/honeycomb-server/src/app.ts):
// 注册 SSE 端点
app.get("/sse", createMcpRouteHandler(mcpHandlersMap, "get"));
app.post("/messages", createMcpRouteHandler(mcpHandlersMap, "post"));
错误响应会列出当前可用的 MCP ID,方便客户端自查配置。
模块 3:配置管理与热刷新
通过 REST API 管理配置;启动/停止服务时更新数据库状态,并调用 refreshMcpServices 重建内存映射。
核心代码(packages/honeycomb-server/src/routes/configs.ts):
/**
* POST /api/config/:id/start - 启动服务
*/
export async function startConfigHandler(
req: express.Request,
res: express.Response,
handlersMap: Map<number, McpHandlers>,
) {
const id = validateIdParam(req);
const databaseClient = await getDatabaseClient();
// 更新数据库状态为 RUNNING
await databaseClient.updateConfig(id, {
status: StatusEnum.RUNNING,
last_modified: getCurrentTimeString(),
});
await databaseClient.save();
// 刷新 MCP 服务(重新加载所有配置)
await refreshMcpServices(handlersMap);
const updatedConfig = await databaseClient.getConfigWithTools(id);
res.json(createSuccessResponse(dbToVO(updatedConfig)));
}
/**
* 刷新 MCP 服务(重新加载所有配置)
*/
export async function refreshMcpServices(handlersMap: Map<number, McpHandlers>): Promise<void> {
// 清空现有映射
handlersMap.clear();
// 重新创建所有服务
const newHandlersMap = await createMcpServices();
// 更新映射表
newHandlersMap.forEach((handlers, id) => {
handlersMap.set(id, handlers);
});
}
refreshMcpServices 清空并重建映射表,实现无重启热更新;SQL.js 作为嵌入式存储,无需额外数据库进程。
模块 4:JSON Schema → Zod
数据库中的工具参数以 JSON Schema 存储,加载时需转换为 Zod 以做运行时校验:
/**
* 将 JSON Schema 转换为 Zod schema
*/
function jsonSchemaToZod(schemaObj: Record<string, any>): z.ZodObject<any> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [key, value] of Object.entries(schemaObj)) {
if (typeof value === "object" && value !== null) {
const fieldSchema = value as { type?: string; description?: string };
let zodType: z.ZodTypeAny;
// 根据 JSON Schema 的 type 创建对应的 Zod 类型
switch (fieldSchema.type) {
case "string":
zodType = z.string();
break;
case "number":
zodType = z.number();
break;
case "integer":
zodType = z.number().int();
break;
case "boolean":
zodType = z.boolean();
break;
case "array":
zodType = z.array(z.any());
break;
case "object":
zodType = z.object({});
break;
default:
zodType = z.any();
}
// 如果有 description,添加描述
if (fieldSchema.description) {
zodType = zodType.describe(fieldSchema.description);
}
shape[key] = zodType;
} else {
shape[key] = z.any();
}
}
return z.object(shape);
}
覆盖 string、number、boolean、array、object 等常见类型;未知类型回退为 z.any(),并保留 description 字段。
注意点
MCP_ID 返回 404
常见原因是服务未处于 RUNNING、ID 填错,或配置已删除但客户端仍缓存旧 ID:
# 通过 API 查询所有可用的服务
curl http://localhost:3002/api/configs
# 检查服务状态
# 确保服务状态为 "running"
热刷新后 SSE 断开
refreshMcpServices 会销毁并重建所有 McpServer 实例,现有长连接随之失效——这是预期行为。生产环境可在刷新前通知客户端重连,或实现更细粒度的增量更新。
SQL.js 写入失败
多为文件权限或锁冲突,检查 mcp.db 权限即可:
# 检查文件权限
ls -l mcp.db
# 修改文件权限
chmod 644 mcp.db
对比与选型
架构差异
性能与稳定性
方案 1 的优势在于进程级隔离:单个 MCP 崩溃不影响其他服务;Nginx 成熟稳定,也便于按服务独立调优。代价是多一跳代理、多份进程开销。
方案 2 走单进程路径,内存更省、路由更短,服务创建与销毁也更快。但稳定性更依赖进程内隔离——某个 handler 的未捕获异常可能影响整个实例。
适用场景
更适合方案 1:
- 需要严格的服务隔离与独立监控/日志
- 团队已有 Nginx 基础设施,服务列表相对稳定
- 生产环境对可用性要求高
更适合方案 2:
- 需要频繁增删 MCP 服务,或提供可视化配置界面
- 资源受限(边缘节点、开发机)
- 快速迭代、本地联调
选型速查
进阶方向
性能优化
方案 1
- 配置 upstream 连接池,复用后端连接
- 同一 MCP 多实例 + 负载均衡
- 对可缓存的响应设置代理缓存策略
方案 2
- 懒加载:首次请求时再实例化 MCP 服务
- 启动预热:提前加载高频服务
- SSE 连接复用,降低握手开销
可扩展场景
- 版本管理:通过
MCP-Version 等 Header 做多版本并存与灰度
- 可观测性:接入 Prometheus、健康检查、告警规则
- 多租户:以 Tenant ID 路由,配合配额与限流
生产环境注意点
- 安全:Header 校验、认证授权(如 JWT)、全链路 HTTPS
- 高可用:健康检查、自动摘除异常实例、配置备份与恢复
- 可观测:结构化日志、OpenTelemetry 追踪、告警通知
小结
两种方案都实现了「统一入口、多 MCP 并存」的目标,但隔离模型不同:
- 方案 1(Nginx):每个 MCP 独占端口与进程,由 Nginx 按
X-Target-Port 转发。隔离强、运维面分散,适合生产环境与服务列表稳定的场景。
- 方案 2(Express):单进程 + 内存映射,按
MCP_ID 路由,配置存 SQL.js 并支持热刷新。灵活轻量,适合频繁变更与资源受限的场景。
各自的局限也值得关注:方案 1 依赖 Nginx、改配置要重启、端口数量有上限;方案 2 单点风险更高,映射表需防泄漏,SQL.js 也不适合高并发写入。
后续值得探索的方向包括:按服务类型组合两种方案的混合架构、接入 Istio 等服务网格、Kubernetes 云原生部署,以及 WebSocket/gRPC 等更多传输方式的支持。
参考资料
-
MCP 协议
-
框架与基础设施
-
相关项目
-
SSE