单实例多 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 单独暴露地址与端口。

这样做的好处很直接:资源占用更低、配置与日志集中管理、部署面更单一,也便于按需增删服务。两种方案都遵循这一思路,差异在于隔离粒度与运维模型。

两种方案一览

维度方案 1:Nginx 反向代理方案 2:Express 统一服务
架构模式反向代理 + 多端口后端单端口 + 应用内路由
服务隔离物理隔离(独立端口/进程)逻辑隔离(内存映射)
路由标识X-Target-PortMCP_ID
配置来源静态 config.jsonSQL.js 数据库 + REST API
变更方式改配置后重启支持热刷新

方案一:Nginx 反向代理

架构设计

方案 1 将 Nginx 作为统一入口,各 MCP 服务仍监听独立端口,由代理按 Header 转发:

客户端请求

Nginx(端口 80)
    ↓  读取 X-Target-Port
后端 MCP 服务
    ├── 服务 1(8080)
    ├── 服务 2(8081)
    ├── 服务 3(8082)
    └── 服务 4(8083)

请求链路如下:

  1. 客户端访问 Nginx(默认 80 端口),在 Header 中带上 X-Target-Port
  2. Nginx 根据该 Header 将流量转发到对应后端端口
  3. 目标 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 反向代理

mapX-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(配置持久化)

请求链路如下:

  1. 客户端访问 Express(3002),Header 携带 MCP_ID
  2. 路由中间件解析 ID,从内存映射表取出对应 handlers
  3. 调用 GET/POST handler 完成 MCP 协议交互
  4. 配置变更写入 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:Nginx 反向代理方案 2:Express 统一服务
服务隔离物理隔离(独立端口/进程)逻辑隔离(同进程、内存映射)
资源占用偏高(多进程)偏低(单进程)
变更成本改配置后需重启支持热刷新
配置管理静态 config.jsonSQL.js + REST API
运维复杂度需同时维护 Nginx 与多个后端单一服务,面更集中

性能与稳定性

方案 1 的优势在于进程级隔离:单个 MCP 崩溃不影响其他服务;Nginx 成熟稳定,也便于按服务独立调优。代价是多一跳代理、多份进程开销。

方案 2 走单进程路径,内存更省、路由更短,服务创建与销毁也更快。但稳定性更依赖进程内隔离——某个 handler 的未捕获异常可能影响整个实例。

适用场景

更适合方案 1:

  • 需要严格的服务隔离与独立监控/日志
  • 团队已有 Nginx 基础设施,服务列表相对稳定
  • 生产环境对可用性要求高

更适合方案 2:

  • 需要频繁增删 MCP 服务,或提供可视化配置界面
  • 资源受限(边缘节点、开发机)
  • 快速迭代、本地联调

选型速查

你的需求建议
熟悉 Nginx,服务数量少且稳定方案 1
需要生产级进程隔离与高可用方案 1
频繁变更服务、希望热更新方案 2
资源紧张、希望统一配置入口方案 2

进阶方向

性能优化

方案 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 等更多传输方式的支持。

参考资料

  1. MCP 协议

  2. 框架与基础设施

  3. 相关项目

  4. SSE