我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚
背景
对于公司内部的MCP-Server, 由于隐私性问题不能发布为npm包,那么就没法以npx或者uvx等形式快速的共享使用。所以基本会以STDIO类型的MCP-Server进行开发,在内部进行共享时只能将对应源文件拉取本地使用。
MCP-Server市场中心能够整合内部各团队或个人实现的MCP-Server,提供统一的Streamable HTTP协议访问端口,快速的整合MCP资源。
现有方案分析
MCP Market有很多网站已经实现,但基本只能做到聚合功能,即只提供检索与使用概览等基本功能,使用时仍需要本地克隆或者 npx 安装调用或者使用MCP注册者自己提供的HTTP调用url。
MCPMarket
仅仅做了检索整合

百炼-MCP
bailian.console.aliyun.com/?spm=a2c4g....
实现了MCP服务的一体化流程,包括检索,部署与一键集成


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

其内部可以实现不同协议类型的 MCP 服务器转化为统一的 streamable-http 类型,它本身提供一个 nacos mcp 来与客户端交互:
Nacos MCP Router 有两种工作模式:
- router模式:默认模式,通过MCP Server推荐、安装及代理其他MCP Server的功能,帮助用户更方便的使用MCP Server服务。
- proxy模式:使用环境变量MODE=proxy指定,通过简单配置可以把sse、stdio协议MCP Server转换为streamableHTTP协议MCP Server。
在router 模式下,Nacos MCP Router 作为一个标准MCP Server,提供MCP Server推荐、分发、安装及代理其他MCP Server的功能。其主要工具列表为
- search_mcp_server
- 根据任务描述及关键字从MCP注册中心(Nacos)中搜索相关的MCP Server列表
- 输入:
task_description(string): 任务描述,示例:今天杭州天气如何key_words(string): 任务关键字,示例:天气、杭州
- 输出: list of MCP servers and instructions to complete the task.
- add_mcp_server
- 添加并初始化一个MCP Server,根据Nacos中的配置与该MCP Server建立连接,等待调用。
- 输入:
mcp_server_name(string): 需要添加的MCP Server名字
- 输出: MCP Server工具列表及使用方法
- 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网关
什么是 AI 网关? 
使用AI网关做MCP Server的统一入口代理


优势:

设计概览
前端设计
页面需包含以下几个功能
-
MCP市场 - 用于查询已注册的MCP服务列表,同大多数MCP Market一样,包含使用说明,配置,工具列表等信息

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

-
MCP调试服务 - 使用
@modelcontextprotocol/inspector实现
-
服务管理

后端设计
协议转换
MCP协议已有三个版本: 2024-11-05、2025-03-26、2025-06-18。
主要差异在于协议间的兼容,2024-11-05版本提供了Stdio与SSE两种transport类型,2025-03-26之后的版本提供了Stdio与Streamable 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)
为了解决数据隔离问题,用两种方式解决:
-
独立child_process
为每一个http请求独立创建子进程,消息响应完即销毁,虽能解决数据隔离问题,且每个请求都能定义自己的env信息,但是在20~30并发下,创建子进程的性能消耗会瞬间打满CPU。
-
单体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通信流程:
- SSE transport会开启两个端点,首先客户端会发起一个
GET请求到/sse端点,可以不携带任何请求参数 /sse端点开启一个流,先返回一个消息端点/messages与sessionId如:/messages?sessionId=xxx

- 客户端向
/messages端点发起实际请求,如 initialize, tools/list,注意此时服务端不会把数据结果支持返回到/messages,而是直接返回202 Accept

/sse端点收到响应结果

- 继续下一轮通信
对于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 。
SSEServerTransport的endpoint仅仅用来指引客户端消息体往哪个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 到本地创建的SSEServerTransport,SSEServerTransport处理请求完成响应。
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响应头,客户端也无法访问GET与DELETE方法,客户端不需要先行通过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层包含以下组成
- 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;
}
}
}
-
MCPRequestManager (请求管理器)
管理待处理请求队列
ID映射管理(客户端ID ↔ 内部ID)
请求超时处理
响应路由回调
-
MCPProcessManager (STDIO transport进程管理器)
进程生命周期管理
进程通信(stdin/stdout)
进程监控和错误处理
进程重启机制
-
MCPSessionManager (会话管理器)
SSE会话生命周期管理
会话超时清理
协议版本管理
会话统计
-
传输处理器 (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请求来识别,数据库中使用status、last_ping_at、ping_error三个字段来存储相关信息。
modelcontextprotocol.io/specificati...
当在注册完成、修改、启动、重启等操作后,应立即调用一次。
除此之外,创建定时任务,每隔一段时间发送一次ping请求。
Inspector集成
提供一个在线调试工具尤为重要,那快速的帮助使用者或者服务器拥有者排查问题。
@modelcontextprotocal/inspector 是一个mcp官方提供的web端调试工具

其实现代码分为前端页面与server端,server端用于跟真实服务器通信。
前端使用vite与radix-ui,后端服务采用exporess
如果要集成进哆啦A梦,考虑三种方案:
- 微服务,将inspector直接按源码级迁移到哆啦A梦中并开放两个新端口,哆啦A梦启动时连带启动inspector前后端服务,需要考虑如何实现打包
- 合并迁移,inspector的代码实现并不复杂,可以考虑与现有哆啦A梦代码整合合并
- 独立服务,将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 进程来做单独的服务,如定时任务。

由于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中的Server与Transport和Client是一对一对一的关系,也就是同时只能一个人访问你的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