☰
跨越网络的连接艺术:基于 SSE 传输层的远程 MCP 服务部署实战,让云端 AI 联动本地资产
2026/9/28 19:01:49 网站建设 项目流程

1. 为什么本地 Stdio 跑不通了:远程 MCP 的真实痛点

如果你用过 MCP(Model Context Protocol),大概率是从 Stdio 传输层开始的:AI 客户端把 MCP Server 当子进程拉起来,通过标准输入输出对话。这套机制在本地很顺,但一旦你想让云端 AI 调用公司内网的文件服务器、测试机上的脚本、或者一台常年开着的资产盘点机,Stdio 就彻底卡住了——它要求 Server 和 Client 在同一台机器上,靠父子进程管道通信。

我遇到的具体场景是这样的:团队有一台内网机器,上面放着构建产物、日志归档和几个内部 CLI 工具,希望云端的大模型助手能直接读取这些文件、触发这些工具,而不是每次手动打包上传。Stdio 方案要么把工具复制到每台客户端,要么写一堆同步脚本,维护成本高得离谱。

SSE(Server-Sent Events)传输层就是为这个场景设计的。它把 MCP 的通信拆成两条 HTTP 通道:客户端用GET /sse建立一条长连接,专门接收服务端推送的事件流;客户端要发指令时,走POST /messages?sessionId=...,服务端处理完再把结果通过那条 SSE 长连接推回来。这种"单向流接收 + 双向 POST 响应"的非对称设计,正好贴合模型长时间流式回传、偶尔发指令的节奏,而且完全跑在标准 HTTP 上,穿防火墙、过 Nginx 都很自然。

这篇就按这个思路走一遍:用 Node.js + Express 搭一个 SSE 传输层的 MCP 服务,用 Nginx 反向代理暴露到公网,再用 curl 验证事件流,最后接上 TaoToken 的统一 Key,让云端 AI 真正联动本地资产。适合已经写过本地 MCP Server、想把它搬到远程的开发者,也适合想理解 SSE 在 MCP 里怎么落地的人。

2. 前置准备:TaoToken 统一 Key 与 MCP 依赖

在写代码之前,先把两件事理清楚:一是 MCP 服务本身的依赖,二是云端 AI 侧怎么拿到统一的接入凭证。

MCP 服务这边,核心依赖是官方 SDK 和 Web 框架。我用的组合是@modelcontextprotocol/sdk+express+cors,TypeScript 可选但推荐,因为 MCP 的请求/响应结构体类型提示能省不少调试时间。Node.js 版本建议 18 以上,SSE 的长连接和fetch相关 API 在新版本上更稳。

云端 AI 侧,如果你用的是支持自定义 MCP 端点的客户端,需要填一个能访问到你公网地址的 URL,以及一个鉴权凭证。这里我用 TaoToken 的统一 Key 来管这件事——它的好处是一个 Key 可以同时对接模型对话、Coding Plan 和 API 调用,不用为每个服务单独维护一套密钥。你可以在控制台里创建 Key,然后在 MCP 客户端的配置里把它作为 Bearer Token 带上。

具体入口我列一下,方便你按需跳转:

  • 模型对话入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat
  • Coding Plan(长期编码/Agent 场景):https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan
  • 控制台:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console
  • API Keys 管理:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api_keys
  • 接入文档:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc

API 基础地址是https://taotoken.net/api,注意这个不带 UTM 参数,直接用于代码里的 base_url。

提示:MCP 服务的鉴权和模型 API 的鉴权是两回事。MCP 服务端校验的是"谁可以连我的 SSE 端点",TaoToken 的 Key 校验的是"谁可以调模型"。两者可以复用同一个 Key,但校验逻辑要分开写,别混在一起。

3. 可复制配置:Express SSE 路由骨架

先建项目、装依赖:

mkdir mcp-remote-sse && cd mcp-remote-sse npm init -y npm install @modelcontextprotocol/sdk express cors npm install -D typescript @types/node @types/express @types/cors npx tsc --init

tsconfig.json里把target设成ES2022,module设成NodeNext,outDir设成dist,其余默认即可。

接下来是核心文件src/server.ts。这里的关键是双路由设计:/sse负责建立事件流,/messages负责接收客户端指令。我用一个Map来管理多个并发会话,避免单连接写法在多客户端场景下互相覆盖。

import express from "express"; import cors from "cors"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; const app = express(); app.use(cors()); app.use(express.json()); // 会话表:sessionId -> transport const transports = new Map<string, SSEServerTransport>(); // MCP 逻辑层 const mcpServer = new Server( { name: "local-asset-bridge", version: "1.0.0" }, { capabilities: { tools: {} } } ); // 注册工具:读取本地文件 mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "read_local_file", description: "读取本地资产目录下的文本文件内容", inputSchema: { type: "object", properties: { relativePath: { type: "string", description: "相对于资产根目录的路径" }, }, required: ["relativePath"], }, }, { name: "list_assets", description: "列出本地资产目录下的文件清单", inputSchema: { type: "object", properties: {} }, }, ], })); const ASSET_ROOT = process.env.ASSET_ROOT || "/srv/assets"; mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "list_assets") { const entries = await fs.readdir(ASSET_ROOT, { withFileTypes: true }); const list = entries.map((e) => `${e.isDirectory() ? "[D]" : "[F]"} ${e.name}`); return { content: [{ type: "text", text: list.join("\n") }] }; } if (name === "read_local_file") { const rel = String(args?.relativePath ?? ""); const full = path.resolve(ASSET_ROOT, rel); // 防目录穿越 if (!full.startsWith(path.resolve(ASSET_ROOT))) { throw new Error("路径越界,拒绝访问"); } const content = await fs.readFile(full, "utf-8"); return { content: [{ type: "text", text: content.slice(0, 8000) }] }; } throw new Error(`未知工具: ${name}`); }); // SSE 建流 app.get("/sse", async (req, res) => { const transport = new SSEServerTransport("/messages", res); transports.set(transport.sessionId, transport); res.on("close", () => { transports.delete(transport.sessionId); }); await mcpServer.connect(transport); }); // 接收指令 app.post("/messages", async (req, res) => { const sessionId = String(req.query.sessionId ?? ""); const transport = transports.get(sessionId); if (!transport) { res.status(400).send("无有效 SSE 会话"); return; } await transport.handlePostMessage(req, res); }); const PORT = Number(process.env.PORT || 3000); app.listen(PORT, () => { console.log(`MCP SSE 服务已启动: http://localhost:${PORT}/sse`); });

几个容易踩的点先标出来。第一,SSEServerTransport的构造函数第一个参数是消息回传路径,必须和你的POST路由一致,写错了客户端发的指令就找不到入口。第二,res.on("close")里一定要清理transports,否则长连接断开后会话表会一直涨,内存泄漏。第三,read_local_file里的路径校验不能省,远程服务暴露到公网后,目录穿越是最常见的攻击面。

编译并启动:

npx tsc ASSET_ROOT=/srv/assets node dist/server.js

看到MCP SSE 服务已启动就说明逻辑层通了。

4. Nginx 反向代理:让 SSE 流不被缓冲吃掉

本地跑通不代表公网能用。SSE 最大的坑就在 Nginx 默认会缓冲响应,导致事件流被攒成一坨再发,客户端看起来就是"卡住不动"。必须在代理层显式关掉缓冲。

下面这份配置可以直接改域名用:

server { listen 443 ssl; server_name mcp.example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; location /sse { proxy_pass http://127.0.0.1:3000/sse; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; proxy_cache off; chunked_transfer_encoding off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } location /messages { proxy_pass http://127.0.0.1:3000/messages; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

proxy_buffering off和proxy_cache off是 SSE 能实时推送的前提,chunked_transfer_encoding off避免分块编码和 SSE 的事件边界打架。proxy_read_timeout拉到 3600 秒,是因为 MCP 的长连接可能长时间没有数据,默认 60 秒会被 Nginx 主动掐断。

改完配置nginx -t检查语法,然后nginx -s reload。

注意:/messages这个 location 不需要关缓冲,因为它是普通的 POST 请求,走完就返回。只有/sse需要特殊处理。把两个 location 分开写,别图省事合成一个。

5. 验证请求:curl 看 SSE 事件流 + 完整调用链

配置好之后,先用 curl 确认事件流真的在推。SSE 的响应是text/event-stream,curl 会一直挂着,你能看到event:和data:交替出现。

curl -N -H "Accept: text/event-stream" https://mcp.example.com/sse

正常的话会先收到一条endpoint事件,里面带着sessionId:

event: endpoint data: /messages?sessionId=8f3a1c2e-...

拿到 sessionId 后,另开一个终端发指令。MCP 的 JSON-RPC 请求体长这样,先列工具:

curl -X POST "https://mcp.example.com/messages?sessionId=8f3a1c2e-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }'

POST 请求本身会返回一个 202,真正的结果通过刚才那条 SSE 流推回来。你会在第一个终端里看到:

event: message data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_local_file",...}]}}

再调一次工具,验证本地文件真的被读到了:

curl -X POST "https://mcp.example.com/messages?sessionId=8f3a1c2e-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "read_local_file", "arguments": { "relativePath": "build/report.txt" } } }'

SSE 流里会推回文件内容。到这一步,整条链路就通了:公网请求 → Nginx → Express → MCP 逻辑层 → 本地文件系统。

接下来把 MCP 端点接到云端 AI 客户端。在客户端的 MCP 配置里填https://mcp.example.com/sse,鉴权部分用 TaoToken 的 Key。如果你用的是支持config.toml的客户端,片段大概是这样:

[mcp_servers.local_asset_bridge] url = "https://mcp.example.com/sse" bearer_token = "sk-你的TaoToken统一Key"

这个 Key 从 API Keys 页面拿:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api_keys

服务端如果要校验这个 Bearer Token,在/sse路由开头加一段中间件即可:

app.get("/sse", async (req, res) => { const auth = req.headers.authorization ?? ""; const token = auth.replace(/^Bearer\s+/i, ""); if (token !== process.env.MCP_TOKEN) { res.status(401).send("未授权"); return; } // ... 后续建流逻辑 });

6. 本篇常见错排查

SSE 连上但收不到任何事件。九成是 Nginx 缓冲没关。检查proxy_buffering off是否写在/sse的 location 里,而不是写在 server 块外层。另外确认proxy_http_version 1.1有写,HTTP/1.0 不支持长连接。

POST /messages 返回 400 "无有效 SSE 会话"。说明 sessionId 对不上。常见原因是客户端在 SSE 断开后重连,拿到了新 sessionId,但还在用旧的发指令。服务端这边res.on("close")清理会话的时机要确认,别在连接还活着的时候就把 transport 删了。

curl 能看到事件流,但 AI 客户端连不上。大概率是客户端不支持自定义 Header,而你的服务端强制要求 Bearer Token。解决办法是在 URL 里带临时 token,比如/sse?token=xxx,服务端从 query 里取。注意这种 token 要短时效,别用长期 Key。

工具调用返回 "路径越界,拒绝访问"。这是路径校验生效了,说明你传的relativePath解析后跑到了ASSET_ROOT外面。检查是不是用了绝对路径,或者..没被正确 resolve。这个报错是好事,别把它关掉。

长时间空闲后连接被掐断。Nginx 的proxy_read_timeout默认 60 秒,SSE 空闲超过这个时间就会被断。上面配置里拉到 3600 秒能缓解,但更稳的做法是服务端定期发心跳注释行: ping\n\n,让连接保持活跃。

TaoToken Key 在模型侧能用,MCP 侧报 401。确认两边的校验逻辑是分开的。MCP 服务端校验的是MCP_TOKEN环境变量,模型 API 校验的是 TaoToken 的 Key。如果你想让它们复用同一个值,把MCP_TOKEN设成同一个 Key 就行,但代码里别把两套校验混在一个中间件里。

7. 接入与排障:按场景选对入口

走到这里,远程 MCP 服务已经能跑通,云端 AI 也能通过 SSE 调用本地资产了。剩下的是按你的实际场景选对后续入口。

如果你卡在接入环节,比如 Bearer Token 怎么传、config.toml字段名对不上、或者 curl 验证时事件流格式和预期不一致,优先看接入文档和 API Keys 管理页,那里有完整的鉴权说明和 Key 创建流程:

  • 接入文档:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc
  • API Keys:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api_keys

如果你只是想先验证模型侧能不能正常对话、确认 Key 有没有生效,直接去模型对话页面发一条消息最快:

  • 模型对话:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat

如果你打算把这个 MCP 服务长期挂在 Coding Plan 或 Agent 工作流里,让 AI 反复调用本地工具,那 Coding Plan 的额度模型更适合这种高频场景:

  • Coding Plan:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan

最后补一个实战里省时间的技巧:调试 SSE 时别每次都重启 Node 服务,用nodemon监听dist目录,配合tsc -w增量编译,改完代码 Nginx 那边不用动,curl 重连一次就能看到新逻辑。另外transports这个 Map 建议加个定时清理,每 5 分钟扫一遍超过 10 分钟没活动的 session,防止客户端异常断开后残留。这两点做完,这套远程 MCP 服务基本可以长期挂着跑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询