如何实现 Remote MCP-Server

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚

背景

对于公司内部的MCP-Server, 由于隐私性问题不能发布为npm包,那么就没法以npx或者uvx等形式快速的共享使用。所以基本会以STDIO类型的MCP-Server进行开发,在内部进行共享时只能将对应源文件拉取本地使用。

MCP-Server市场中心能够整合内部各团队或个人实现的MCP-Server,提供统一的Streamable HTTP协议访问端口,快速的整合MCP资源。

现有方案分析

MCP Market有很多网站已经实现,但基本只能做到聚合功能,即只提供检索与使用概览等基本功能,使用时仍需要本地克隆或者 npx 安装调用或者使用MCP注册者自己提供的HTTP调用url。

MCPMarket

mcpmarket.com/zh/server

仅仅做了检索整合

百炼-MCP

bailian.console.aliyun.com/?spm=a2c4g....

实现了MCP服务的一体化流程,包括检索,部署与一键集成

Nacos-AI

Nacos-AI

nacos本身是一个动态服务注册发现与管理框架,新版本提供了Nacos MCP Router来管理所有MCP

其内部可以实现不同协议类型的 MCP 服务器转化为统一的 streamable-http 类型,它本身提供一个 nacos mcp 来与客户端交互:

Nacos MCP Router 有两种工作模式:

  1. router模式:默认模式,通过MCP Server推荐、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
  2. proxy模式:使用环境变量MODE=proxy指定,通过简单配置可以把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。

在router 模式下,Nacos MCP Router 作为一个标准MCP Server,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能。其主要工具列表为

  1. search_mcp_server
    • 根据任务描述及关键字从MCP注册中心(Nacos)中搜索相关的MCP Server列表
    • 输入:
      • task_description(string): 任务描述,示例:今天杭州天气如何
      • key_words(string): 任务关键字,示例:天气、杭州
    • 输出: list of MCP servers and instructions to complete the task.
  2. add_mcp_server
    • 添加并初始化一个MCP Server,根据Nacos中的配置与该MCP Server建立连接,等待调用。
    • 输入:
      • mcp_server_name(string): 需要添加的MCP Server名字
    • 输出: MCP Server工具列表及使用方法
  3. use_tool
    • 代理其他MCP Server的工具
    • 输入:
      • mcp_server_name(string): 被调的目标MCP Server名称.
      • mcp_tool_name(string): 被调的目标MCP Server的工具名称
      • params(map): 被调的目标MCP Server的工具的参数
    • 输出: 被调的目标MCP Server的工具的输出结果

在proxy 模式下,Nacos MCP Router 仅提供代理功能,无需代码改动即可实现stdio、sse协议一键转换为streamableHTTP协议。

higress AI网关

higress AI网关

什么是 AI 网关?

使用AI网关做MCP Server的统一入口代理

优势:

设计概览

前端设计

页面需包含以下几个功能

  1. MCP市场 - 用于查询已注册的MCP服务列表,同大多数MCP Market一样,包含使用说明,配置,工具列表等信息

  2. MCP注册中心 - 发布自己实现的MCP,针对 Stdio 类型MCP Server,提供资源部署服务

  3. MCP调试服务 - 使用 @modelcontextprotocol/inspector实现

  4. 服务管理

后端设计

协议转换

MCP协议已有三个版本: 2024-11-05、2025-03-26、2025-06-18。

主要差异在于协议间的兼容,2024-11-05版本提供了StdioSSE两种transport类型,2025-03-26之后的版本提供了StdioStreamable HTTP协议。

SSE由于其会使用两个端点/sse/messages且一直会占用长连接的缘故存在设计缺陷,所以我们一般不推荐使用。

基于此,我们定义三种协议的转化规则: Stdio => HTTP , SSE => SSE, HTTP => HTTP

Stdio转HTTP

大部分情况下我们会以Stdio形式开发MCP Server,因为它足够简单,Stdiotransport是以子进程的方式实现 Client 与 Server 之间通信的,实现一对一的关系。

一般有两种使用方式, 在配置时使用 command 指定:

  • npx -y xxx
  • node ./xxx.js

我们在任务管理器中可以看到所有配置的MCP都被以子进程的形式启动了

问:

假设一个MCP只会在全局创建一个进程,那么如果我开启两个Cursor,同时调用同一个MCP,会如何?

如果将其转为HTTP, 需要做到数据隔离。因为StdioClientTransport与运行Server代码的子进程间是通过事件订阅来接收消息的, 接收到消息时我们需要知道这个消息该回传给哪个http请求。

typescript 复制代码
// 接收消息
process.stdout?.on('data', (buffer) => {});
// 发送请求
process.stdin?.write(msg)

为了解决数据隔离问题,用两种方式解决:

  1. 独立child_process

    为每一个http请求独立创建子进程,消息响应完即销毁,虽能解决数据隔离问题,且每个请求都能定义自己的env信息,但是在20~30并发下,创建子进程的性能消耗会瞬间打满CPU。

  2. 单体child_process

    单体child_process对于每一个MCP Server只会创建一个子进程,且进程会随哆啦A梦一起启动。

由于不管是哪种 transport,都是以 JSON-RPC 协议进行消息定义,JSON-RPC可以提供 id 参数标识请求,如果请求中携带了id,那么响应的 JSON-RPC 也必须携带同样的id。

client在请求时会自己生成一个id,但这个id我们并不能直接使用,如果同时有多个用户在 client 端发送请求,它们的id可能会一样,因此我们需要内部维护一个唯一的id,同时维护内部id与client生成的id的关系。

在与Server通信时,使用内部id以保证唯一,当Server返回数据时,将JSON-RPC响应体的内部id替换为client id以此实现数据隔离。

SSE

SSE通信流程:

  1. SSE transport会开启两个端点,首先客户端会发起一个GET请求到/sse端点,可以不携带任何请求参数
  2. /sse端点开启一个流,先返回一个消息端点/messagessessionId如:/messages?sessionId=xxx
  3. 客户端向/messages端点发起实际请求,如 initialize, tools/list,注意此时服务端不会把数据结果支持返回到/messages,而是直接返回202 Accept
  4. /sse端点收到响应结果
  5. 继续下一轮通信
    对于SSE, 我们在Controller层同样开放两个端点
javascript 复制代码
app.post('/mcp-endpoint/:serverId/messages', app.controller.mcp.handleMCPEndpointPost);
app.get('/mcp-endpoint/:serverId/sse', app.controller.mcp.handleMCPEndpointGet);

如果单纯的做代理转发,那么无法做到对消息端点 的转发,因为消息端点是MCP Server动态定义的,可能不是/messages,所以我们需要在哆啦A梦层动态创建 SSEServerTransport

SSEServerTransportendpoint仅仅用来指引客户端消息体往哪个API接口发送, 所以我们在哆啦A梦后端创建的endpoint与真实MCP服务器返回的endpoint是没有关系与冲突的。

javascript 复制代码
const messageEndpoint = 'http://localhost:7001/mcp-endpoint/' + serverId + '/messages';

const serverTransport = new SSEServerTransport(messageEndpoint, res, {
    headers,
});
await serverTransport.start();

我们在哆啦A梦创建的SSEServerTransport能够帮我们处理客户端发送的SSE请求并自动响应,但是我们目前没有绑定任何的MCP Server, 它仅有一个桥接的作用。

因此,我们还需要在哆啦A梦层创建一个SSEClientTransport, 将我们从真实客户端发送的消息通过SSEClientTransport再发送给真实MCP服务器。

SSEClientTransport监听onmessage获取真实MCP服务器消息结果后,将结果 send 到本地创建的SSEServerTransportSSEServerTransport处理请求完成响应。

GET请求处理如下:

javascript 复制代码
/**
 * 处理GET请求
 * @param {string} serverId - 服务器ID
 * @param {any} req - 请求数据
 * @param {object} res - 响应数据
 */
async handleGet(serverId, req, res) {
  const sseProxy = this.sseProxies.get(serverId);
  if (!sseProxy) {
    throw new Error(`服务器 ${serverId} 的SSE连接未找到`);
  }

  let transportToClientClosed = false;
  let transportToServerClosed = false;

  const headers = {
    ...req.headers,
    ...sseProxy.headers,
    accept: 'text/event-stream',
  };

  const clientTransport = new SSEClientTransport(new URL(sseProxy.url), {
    headers,
  });
  await clientTransport.start();

  const messageEndpoint = 'http://localhost:7001/mcp-endpoint/' + serverId + '/messages';
  const serverTransport = new SSEServerTransport(messageEndpoint, res, {
    headers,
  });
  await serverTransport.start();

  this.serverTransports.set(serverTransport.sessionId, serverTransport);
  this.clientTransports.set(serverTransport.sessionId, clientTransport);

  // 桥接数据交换
  serverTransport.onmessage = (message) => clientTransport.send(message);
  clientTransport.onmessage = (message) => serverTransport.send(message);
  serverTransport.onclose = () => {
    if (transportToServerClosed) {
      return;
    }

    transportToClientClosed = true;
    clientTransport.close().catch((e) => console.log(e));
  };
  clientTransport.onclose = () => {
    if (transportToClientClosed) {
      return;
    }
    transportToServerClosed = true;
    serverTransport.close().catch((e) => console.log(e));
  };

}

POST消息请求处理如下:

javascript 复制代码
/**
 * 处理POST请求
 * @param {string} serverId - 服务器ID
 * @param {any} req - 请求数据
 * @param {object} res - 响应数据
 */
async handlePost(serverId, req, res) {
  const sseProxy = this.sseProxies.get(serverId);
  if (!sseProxy) {
    throw new Error(`服务器 ${serverId} 的SSE连接未找到`);
  }

  const serverTransport = this.serverTransports.get(req.query.sessionId);

  if (!serverTransport) {
    throw new Error(`session ${req.query.sessionId} 不存在`);
  }

  try {
    // 由于Egg.js已经解析了请求体,我们需要将解析好的body传递给handlePostMessage
    await serverTransport.handlePostMessage(req, res, req.body);
  } catch (error) {
    console.error(`SSE POST请求处理错误:`, error);
    throw error;
  }
}
Streamable HTTP

如果本身就是Streamable HTTP类型的,我们只做请求的代理,将目标服务器或者托管在哆啦A梦不同端口上的MCP服务,统一通过哆啦A梦mcp端点访问。

代理实现与SSE相似,此处略。

但注意Streamable HTTP使用一个/mcp端点,但是可以使用3种方式访问:POST、GET、DELETE。

Stateless VS Statefulness

Streamable HTTP MCP在定义时存在无状态与有状态模式的区分:

typescript 复制代码
// 有状态
transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => sessionId,
  onsessioninitialized: (id) => {
    console.log(`新会话初始化: ${id}`);
  }
});

// 无状态
transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
});

在无状态模式下,不会返回MCP-Session-Id响应头,客户端也无法访问GETDELETE方法,客户端不需要先行通过initialize进行握手连接,可以直接调用目标方法,缺点就是服务器端没法进行实时通知,如ToolListChanged等消息。

Stdio本身是以一对一连接存在的,且会长期维持子进程的存在,所以属于statefulness这一类, 当转化为Streamable HTTP时,需要我们自行管理session(当前暂时处理为stateless);

SSE肯定是以Statefulness存在,我们会在后端存储代理的session信息。

Streamable HTTP 我们走完全代理模式,是否需要状态由目标MCP服务器决定。

数据库设计

采用单表设计,不需要额外关联表

sql 复制代码
CREATE TABLE IF NOT EXISTS `mcp_servers` (
    `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键',
    `server_id` varchar(64) NOT NULL COMMENT '服务器唯一标识/名称',
    `title` varchar(200)  NOT NULL COMMENT '显示标题',
    `description` text  COMMENT '服务器描述(支持Markdown)',
    `author` varchar(100)  NOT NULL COMMENT '创建者',
    `version` varchar(20)  NOT NULL COMMENT '版本号',
    `tags` json COMMENT '标签数组',
    `transport` enum('stdio', 'sse', 'streamable-http')  NOT NULL DEFAULT 'stdio' COMMENT '传输协议类型',
    `command` varchar(255)  COMMENT '启动命令(stdio类型)',
    `args` json COMMENT '命令参数数组(stdio类型)',
    `env` json COMMENT '环境变量对象(stdio类型)',
    `http_url` varchar(500)  COMMENT 'HTTP访问地址(http类型)',
    `sse_url` varchar(500)  COMMENT 'SSE访问地址(sse类型)',
    `git_url` varchar(500)  COMMENT 'Git源码地址',
    `deploy_path` varchar(500)  COMMENT '托管部署路径',
    `status` tinyint NOT NULL DEFAULT '1' COMMENT '服务器状态 1-启用 0-禁用',
    `is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 1-已删除 0-未删除',
    `use_count` int NOT NULL DEFAULT '0' COMMENT '使用次数',
    `tools` json COMMENT '可用工具列表',
    `prompts` json COMMENT '可用提示词列表',
    `resources` json COMMENT '可用资源列表',
    `capabilities` json COMMENT '服务器能力信息',
    `last_sync_at` datetime COMMENT '最后同步时间',
    `runtime_status` enum('running', 'stopped', 'error', 'unknown') NOT NULL DEFAULT 'unknown' COMMENT '运行时状态 running-运行中 stopped-已停止 error-错误 unknown-未知',
    `last_ping_at` datetime COMMENT '最后ping检查时间',
    `ping_error` text COMMENT '最后ping检查错误信息',
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_server_id` (`server_id`),
  ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COLLATE = utf8_bin

控制层

支持 SSE 与 Streamable HTTP端点

javascript 复制代码
/**
 * MCP代理端点
 */
app.post('/mcp-endpoint/:serverId/mcp', app.controller.mcp.handleMCPEndpointPost);
app.get('/mcp-endpoint/:serverId/mcp', app.controller.mcp.handleMCPEndpointGet);
app.delete('/mcp-endpoint/:serverId/mcp', app.controller.mcp.handleMCPEndpointDelete);


app.post('/mcp-endpoint/:serverId/messages', app.controller.mcp.handleMCPEndpointPost);
app.get('/mcp-endpoint/:serverId/sse', app.controller.mcp.handleMCPEndpointGet);

服务层

MCPService层

MCPService层包含所有数据库模型操作,但不包含具体的MCP处理,如MCP启动,重启。

javascript 复制代码
const mcpProxy = MCPProxy.getInstance();

class MCPController extends Controller {
    async handleMCPEndpointGet() {
      const { ctx } = this;
      const { serverId } = ctx.params;
      await mcpProxy.forwardRequest(serverId, ctx.request, ctx.response);
      ctx.respond = false;
  }
}

MCPProxy层

MCPProxy层包含以下组成

  1. MCPProxy (核心代理)
    单例模式,统一管理所有MCP服务器连接
    路由请求到对应的传输处理器
    管理服务器生命周期(启动/停止/重启)
javascript 复制代码
class MCPProxy {

  constructor(){
    this.requestManager = new MCPRequestManager();
    this.processManager = new MCPProcessManager();
    this.stdioHandler = new StdioTransportHandler(this.processManager, this.requestManager);
    this.sseHandler = new SSETransportHandler();
    this.httpHandler = new StreamableHttpTransportHandler();
  }
  // 单例实现
  getInstance()

  // 缓存MCP Server配置与forward具体的协议转换处理
  async startProxy(serverId, config) {
    try {
      // 停止已存在的代理
      await this.stopProxy(serverId);

      const transport = config.transport || { type: 'stdio' };
      let handler;

      switch (transport.type) {
        case 'stdio':
          handler = this.stdioHandler;
          break;
        case 'sse':
          handler = this.sseHandler;
          break;
        case 'streamable-http':
          handler = this.httpHandler;
          break;
        default:
          throw new Error(`不支持的传输类型: ${transport.type}`);
      }

      // 启动传输处理器
      await handler.start(serverId, config);

      // 记录使用的处理器
      this.transportHandlers.set(serverId, handler);

      console.log(`MCP代理已启动: ${serverId} (${transport.type})`);
    } catch (error) {
      console.error(`启动MCP代理失败 [${serverId}]:`, error);
      throw error;
    }
  }
}
  1. MCPRequestManager (请求管理器)

    管理待处理请求队列

    ID映射管理(客户端ID ↔ 内部ID)

    请求超时处理

    响应路由回调

  2. MCPProcessManager (STDIO transport进程管理器)

    进程生命周期管理

    进程通信(stdin/stdout)

    进程监控和错误处理

    进程重启机制

  3. MCPSessionManager (会话管理器)

    SSE会话生命周期管理

    会话超时清理

    协议版本管理

    会话统计

  4. 传输处理器 (Transport Handlers 核心处理)

    整个协议转换与具体的处理

javascript 复制代码
abstract class BaseTransportHandler {
    start();
    stop();
    // 为MCPProxy提供统一的入口
    forward();
    handlePost();
    handleGet();
}

class StdioTransportHandler extends BaseTransportHandler{}

class SSETransportHandler extends BaseTransportHandler{}

class StreamableHttpTransportHandler extends BaseTransportHandler{}

状态监控

MCP Server注册后是否可用,可以使用使用ClientTransport发送一个ping请求来识别,数据库中使用statuslast_ping_atping_error三个字段来存储相关信息。

modelcontextprotocol.io/specificati...

当在注册完成、修改、启动、重启等操作后,应立即调用一次。

除此之外,创建定时任务,每隔一段时间发送一次ping请求。

Inspector集成

提供一个在线调试工具尤为重要,那快速的帮助使用者或者服务器拥有者排查问题。

@modelcontextprotocal/inspector 是一个mcp官方提供的web端调试工具

其实现代码分为前端页面与server端,server端用于跟真实服务器通信。

前端使用vite与radix-ui,后端服务采用exporess

如果要集成进哆啦A梦,考虑三种方案:

  1. 微服务,将inspector直接按源码级迁移到哆啦A梦中并开放两个新端口,哆啦A梦启动时连带启动inspector前后端服务,需要考虑如何实现打包
  2. 合并迁移,inspector的代码实现并不复杂,可以考虑与现有哆啦A梦代码整合合并
  3. 独立服务,将inspector以iframe的形式嵌入哆啦A梦,需要在哆啦A梦服务器上单独启动inspector, 割裂感很重。

环境要求

@modelcontextprotocal/sdk 要求 node 版本v18以上,哆啦A梦 engines.node 要求>=14 & <=16, 使用 node >=18启动哆啦A梦需要添加环境变量NODE_OPTIONS=--openssl-legacy-provider && egg-bin dev --daemon

多进程集群适配

egg.js在启动时会通过 master 进程带起所有其他子进程,每个worker用来执行我们的实际业务代码(mvc框架),类似于nginx的负载均衡,此外还会创建一个 agent 进程来做单独的服务,如定时任务。

cluster-and-ipc

由于doraemon在生产环境下会开启多进程集群来优化性能,每个worker都会重复实例化我们设计的单例MCPProxy,导致不同worker间的mcp相关状态不一致,mcp-session状态也无法共享。

所以需要在 controller 和 service 中,我们需要移除所有直接对 MCPProxy 的操作,为确保实例唯一与状态同步,将MCP底层服务全部移到 agent 进程中。

MCPProxy的主要功能是转发 MCP 的直接请求,因此我们需要在 agent 中开启一个独立的 http 服务(不能利用Controller)

typescript 复制代码
// agent.js文件

// 初始化MCP HTTP处理器
    const mcpHttpHandler = new MCPHttpHandler(agent.logger);
    let httpServer = null;

    // 创建HTTP服务(用于处理MCP端点请求)
    const startHttpServer = () => {
        const port = process.env.MCP_AGENT_HTTP_PORT || env.mcpAgentHttpPort || 7005;

        httpServer = http.createServer(async (req, res) => {
            await mcpHttpHandler.handleRequest(req, res);
        });

        httpServer.listen(port, () => {
            agent.logger.info(`Agent MCP HTTP服务已启动,监听端口: ${port}`);
        });

        httpServer.on('error', (error) => {
            agent.logger.error('Agent MCP HTTP服务错误:', error);
        });
    };

其次 MCPProxy 还包含 MCP 服务的启停, 而 MCP 启停的时机是通过 worker 中 service 层调用的,因此还需要通过IPC(进程间通信)的方式通知 agent。

typescript 复制代码
// 一个worker的service中
this.app.messenger.sendToAgent('mcpRestart', { serverId });


// agent.js
agent.messenger.on('mcpRestart', async ({ serverId }) => {
    try {
        await McpProxy.getInstance().restartProxy(serverId);
        agent.logger.info(`MCP服务器重启成功 [${serverId}]`);
    } catch (error) {
        agent.logger.error(`MCP服务器重启失败 [${serverId}]:`, error);
    }
});

MCP Server代码编写约束

MCP Server中的ServerTransportClient是一对一对一的关系,也就是同时只能一个人访问你的MCP服务,这对于我们实现Remote-MCP的初衷是不符合的。

即使是新的 transport 协议 streamable-http,也是一对一的关系,当我们在一个tab中调用后,在另一个tab中再次连接调用请求会直接卡住。

官方SDK提供的使用案例中目前存在该问题, Server定义在了外部,导致没法对每个请求分配独立的Transport
modelcontextprotocol/typescript-sdk

typescript 复制代码
// Create an MCP server
const server = new McpServer({
    name: 'demo-server',
    version: '1.0.0'
});

// Set up Express and HTTP transport
const app = express();
app.use(express.json());

app.post('/mcp', async (req, res) => {
    // Create a new transport for each request to prevent request ID collisions
    const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
        enableJsonResponse: true
    });

    res.on('close', () => {
        transport.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
});

const port = parseInt(process.env.PORT || '3000');
app.listen(port, () => {
    console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
}).on('error', error => {
    console.error('Server error:', error);
    process.exit(1);
});

因此,针对 streamable-http 与 sse 类型,需要将Server的创建放在请求处理中:

typescript 复制代码
// 把Server的定义代码封装起来
const createServer = () => { return new McpServer() }

app.post('/mcp', async (req, res) => {
    const server = createServer();
    // Create a new transport for each request to prevent request ID collisions
    const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
        enableJsonResponse: true
    });

    res.on('close', () => {
        transport.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
});

Next

  • 一键转化API请求接口 => Streamable HTTP MCP
  • 弹性启动Stdio进程

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

相关推荐
Java中文社群3 小时前
【保姆级教程】免费使用Gemini3的5种方法!免翻墙/国内直连
aigc
少卿3 小时前
React Compiler 完全指南:自动化性能优化的未来
前端·javascript
广州华水科技3 小时前
水库变形监测推荐:2025年单北斗GNSS变形监测系统TOP5,助力基础设施安全
前端
广州华水科技3 小时前
北斗GNSS变形监测一体机在基础设施安全中的应用与优势
前端
七淮3 小时前
umi4暗黑模式设置
前端
8***B3 小时前
前端路由权限控制,动态路由生成
前端
军军3604 小时前
从图片到点阵:用JavaScript重现复古数码点阵艺术图
前端·javascript
znhy@1234 小时前
Vue基础知识(一)
前端·javascript·vue.js
terminal0074 小时前
浅谈useRef的使用和渲染机制
前端·react.js·面试
我的小月月4 小时前
🔥 手把手教你实现前端邮件预览功能
前端·vue.js