news 2026/9/19 16:52:37

MCP自定义服务器进阶实战:错误处理、流式输出与部署全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCP自定义服务器进阶实战:错误处理、流式输出与部署全解析

我最早接触 MCP 自定义服务器,是从官方那个三行代码的示例开始的。注册一个工具,server.tool(...)一写,客户端立刻就能调用,感觉这玩意儿太简单了。直到我把服务器从“能跑”推向“能用”,才意识到真正的坑全在后面:工具抛错时客户端收到的提示莫名其妙,长任务跑起来像卡死一样,TypeScript 类型到处是any凑合,好不容易写完扔到部署环境又连不上。这篇文章就把我在错误处理、流式输出、TypeScript 类型安全和部署落地这四个方向上的实践经验完整拆开讲一遍,都是些官方示例不会写、但真实项目绕不开的进阶细节。适合已经被 MCP 工具的新奇感钩住、正准备把它做成正经服务的开发者参考。

1. 工具报错别只会 throw new Error:MCP 错误处理的三层防线

MCP 的通信底层是 JSON-RPC 2.0,这意味着它的错误机制天生就是结构化的。但很多人写工具函数的时候,还是习惯像写普通 Node 接口一样直接throw new Error("xxx"),这样写不是不行,而是丢掉了把错误语义传达给调用方的能力。AI 客户端收到一个模糊的“服务器内部错误”时,它只能猜,猜错了就给你瞎编一个原因。所以第一层防线,是把错误从“文本描述”升级成“结构化错误码”。

1.1 用 McpError 而不是裸 Error,规范错误码的意义在哪里

@modelcontextprotocol/sdk里提供了现成的McpError类和ErrorCode枚举,路径一般在@modelcontextprotocol/sdk/types.js。我第一次重构错误处理时,把所有throw new Error(...)全部换成了带错误码的McpError,效果立竿见影。

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // 不推荐:错误信息只有一段人类文本,客户端只能截取片段展示 throw new Error("城市参数不能为空,且必须是合法的城市名称"); // 推荐:错误码 + 可读信息,客户端和 AI 模型都能准确理解 throw new McpError( ErrorCode.InvalidParams, "城市参数不能为空,且必须是合法的城市名称" );

你可能会问,反正都是把错误返给客户端,区别在哪?区别在于错误码给了一个“分类锚点”。InvalidParams代表调用方式有问题,客户端可以引导用户修正参数后再试;ToolExecutionError代表参数没问题但工具内部执行失败了,客户端应该把目标转向排查外部依赖。AI 模型在生成下一步决策时,错误码和错误信息都会进入上下文,错误码越精确,模型越容易做出正确判断。

SDK 的ErrorCode枚举在不同版本里略有差异,但我用的 1.x 版本里常用的几个,这里列一张表方便对号入座:

错误码常量数值典型使用场景
InvalidRequest-32600请求结构本身不合法,比如缺字段
MethodNotFound-32601客户端调用了未注册的工具
InvalidParams-32602工具参数校验不通过
InternalError-32603代码里未捕获的意外异常
RequestTimeout-32001工具执行超过了预设的超时时间
ResourceNotFound-32002按 URI 查找资源时找不到目标
InvalidToolArguments-32004工具入参结构错误,由 SDK 自动抛出
ToolExecutionError-32005工具执行阶段的业务失败

还有个小技巧:错误码和错误信息的关系就像 HTTP 状态码和响应体的关系,错误码管机器可读和逻辑分支,错误信息管人(或 AI)可读。我一般会把给模型看的“下一步建议”也写进错误信息里,比如“仓库不存在,请先调用 createRepo 创建仓库”,这样模型拿到报错后能自主决定要不要换个工具继续尝试。

1.2 参数校验交给 schema,不要在业务代码里手写 if

MCP SDK 对工具参数的处理,本质上是用 schema 描述 JSON 结构。你如果不用 schema,而是在回调函数内部写一大堆if (!params.xxx) throw ...,就把自动校验机制活活浪费了。正确做法是把约束直接声明在server.tool()的第二个参数上,让 SDK 在进入业务逻辑之前就把非法参数拦下来。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; const server = new McpServer({ name: "book-query", version: "1.0.0" }); server.tool( "queryBooks", { keyword: z.string().min(1).max(50), page: z.number().int().min(1).max(1000).optional().default(1), pageSize: z.number().int().min(1).max(50).optional().default(10), }, async (params) => { // 走到这里的 params 已经保证是合法的了 // params 的类型是 { keyword: string; page?: number; pageSize?: number } return { content: [{ type: "text", text: JSON.stringify(await searchBooks(params)) }], }; } );

很多人在这个阶段踩过同一个坑:以为 zod schema 只是给客户端看“参数长什么样”的说明文档,于是 schema 写得极其宽松,所有业务校验都堆在回调里。结果就是,SDK 自带的参数错误分支(InvalidToolArguments)永远不触发,业务代码里却长满了if (!x) throw new Error(...)这种原始代码。

跨字段校验也不要手写进回调。zod 的refinesuperRefine可以在 schema 层就处理“开始日期必须小于结束日期”这类关联校验,这样无论客户端怎么传参,拿到的都是一个结构化的InvalidToolArguments响应,而不是业务层抛出的随机错误。

const dateRange = z .object({ start: z.string().datetime(), end: z.string().datetime(), }) .refine((data) => data.start < data.end, { message: "start 必须早于 end", path: ["start"], }); server.tool("queryLogs", dateRange, async ({ start, end }) => { // 业务实现,无需再判断 start 和 end 的大小关系 });

1.3 工具内部异常:捕获、脱敏、超时一个都不能少

schema 帮你挡住了参数层的错误,但真正危险的异常全在回调内部:数据库连接断了、上游 HTTP 接口超时了、JSON 解析炸了。这些异常如果原样抛出去,MJ 有概率把 Node 的堆栈信息直接带进错误响应,然后 AI 模型抓着at /home/user/project/dist/index.js:42这种信息给你编一个修复方案,非常误导。

我的经验是,不要在回调里把所有逻辑塞进一个大try/catch,而是把“外部依赖调用”单独隔离出来,统一做异常翻译。

server.tool( "fetchUserOrders", { userId: z.string() }, async ({ userId }) => { let data; try { data = await userService.getOrders(userId); // 外部依赖 } catch (err) { if (err instanceof TimeoutError) { throw new McpError( ErrorCode.RequestTimeout, "订单服务查询超时,请稍后重试" ); } // 记录完整堆栈到日志,但响应用户的只保留安全摘要 console.error("[fetchUserOrders]", err); throw new McpError( ErrorCode.ToolExecutionError, "订单服务暂时不可用,请联系运维排查" ); } return { content: [{ type: "text", text: JSON.stringify(data) }] }; } );

这里有几个细节值得说明。第一,脱敏:服务端日志里可以打完整堆栈,但返回给客户端的错误绝不能包含文件路径、环境变量、内网 IP、请求密钥这些信息。第二,超时:很多工具回调是异步的,如果上游接口一直没有返回,工具调用就会一直挂着,客户端那边表现为“模型一直在思考”。我习惯在回调开头用AbortSignal.timeout()包一层,给外部调用加一个硬超时,超时就按RequestTimeout返回。第三,信号量:回调的第二个参数extra里通常有signal,当客户端取消请求时这个信号会被触发,这时候如果你还在跑重型任务,最好能检查并主动放弃,避免无用计算占着资源不放。

2. 别把“流式输出”做成打字机:MCP 协议活用的关键在进度通知

“流式输出”这个词在 MCP 开发话题里的热度一直很高,尤其被 ChatGPT 那种逐字蹦字的交互模式带偏了。很多人一听说“实现流式输出”,第一反应是让工具结果像打字机一样源源不断吐字符。但实际上,在 MCP 协议 v1.0 里,工具调用的最终结果仍然是一个完整的一次性响应,协议层面上并没有“把一段 text 分割成多次增量推送”的原生机制。

2.1 一个常见预期偏差:MCP 工具结果本来就不是流式的

如果你去翻 MCP 客户端 SDK 的源码,会看到工具调用的返回类型是CallToolResult,内容是一个content数组。这个数组是一次性序列化后返回给客户端的。协议没有提供类似“文本分段发送,客户端边收边渲染”的通道。换句话说,你没法用标准 MCP 协议给 Claude 桌面端做一个“模型逐字输出工具结果”的效果。

那为什么还有那么多关于“流式输出”的需求?因为它们大多来自 Web 场景的聊天机器人:浏览器里一个请求发出去,后端用 SSE 或 WebSocket 把大模型的 token 一段段推给前端,页面打字机一样渲染。但在 MCP 上下文里,工具服务器是给 AI 客户端调用的,不是直接给浏览器用的。AI 客户端本身的思考过程才是打字机,工具返回只是一个“中间停顿点”。

所以我在给团队做技术方案时,通常会先把结论拍死:如果你的目标是“工具返回结果需要打字机效果”,那大概率是产品设计跑偏了。工具的结果应该是结构化、完整、一次性的,让模型去消化;而不是像聊天一样让用户盯着工具输出逐字阅读。纠正了这个预期偏差,后面很多设计就好做了。

2.2 用 notifications/progress 给客户端“进行中”的手感

MCP 协议虽然没有流式的工具结果,但它提供了一个很接近流式体验的机制:进度通知(notifications/progress)。如果你的工具要执行一个耗时任务,比如批量处理文件、拉取多个外部接口、跑一个比较重的算法,千万别让客户端干等。你可以在执行过程中主动发进度通知,让客户端侧展示“任务处理到 XX%”的状态。

在 SDK 里发进度通知很简单,直接在工具回调里调用server.notification()即可:

server.tool( "batchProcessImages", { imageUrls: z.array(z.string().url()).min(1).max(50), }, async ({ imageUrls }, extra) => { const { signal } = extra; const total = imageUrls.length; for (let i = 0; i < total; i++) { // 每处理一张图之前,先检查是否有取消信号 if (signal?.aborted) { throw new McpError(ErrorCode.RequestTimeout, "任务已取消"); } await processOneImage(imageUrls[i]); server.notification({ method: "notifications/progress", params: { progress: i + 1, total, progressToken: "batchProcessImages", // 关联到具体的请求 }, }); } return { content: [{ type: "text", text: `成功处理 ${total} 张图片` }], }; } );

这段代码里,progress表示当前完成数,total表示总数。客户端收到进度通知后,一些 MCP 客户端会把当前状态显示成“处理中 12/50”,模型也可以据此感知到任务仍在推进,而不是误判为卡死。

这个机制看起来简单,但有几个实践细节要注意。发通知的频率不要太高,每处理一项就发一次对某些客户端反而是负担,一般控制在每 5%~10% 发一次,或者每 1~2 秒最多发一条。通知和最终结果是异步关系,客户端不保证收到全部通知才能拿结果,所以不要在客户端里把“进度达到 100%”当成“工具必然返回成功”的前置条件,该做的结果判断还是在最终返回里做。在 stdio 模式下也能发通知,SDK 会自动打包成 JSON-RPC 消息通过 stdout 传出去,只是某些宿主可能忽略它,所以不要把所有体验押在进度通知上。

2.3 长文本返回与客户端断连:分页、资源和信号量止损

还有一个和“流式”经常一起被提到的场景:工具要返回的内容特别长,比如一次查出来 10 万行日志。很多人直接JSON.stringify塞进content数组,结果客户端那边要么截断、要么内存暴涨。我见过不少“标签返回未完整”的报错,根源就在这:单次TextContent的内容大小超出了宿主的处理能力上限。

更稳妥的做法是“分页查询 + 资源引用”。具体思路是:工具第一次只返回前 N 条结果,同时给出一个分页标记;模型如果还需要更多,就继续调用带有pageToken参数的下一页工具。这样单次返回体积可控,模型的上下文也不会被一次工具调用灌满。

server.tool( "searchLogs", { keyword: z.string(), pageToken: z.string().optional() }, async ({ keyword, pageToken }) => { const page = await logService.search(keyword, pageToken ?? "first", 200); return { content: [{ type: "text", text: JSON.stringify(page.items) }], // 可以附带下一个分页标记,由模型决定是否继续取 structuredContent: { nextPageToken: page.nextPageToken }, }; } );

如果返回的内容本身适合用“资源”来表达——比如一个 CSV 文件、一张表格、一段 Markdown——也可以考虑注册成 MCP 资源,让工具返回一个resource类型的 content,客户端按需去读取。这个设计更符合 MCP 的“工具 + 资源”双模型理念,比硬塞长文本优雅得多。

断连处理是长任务里最容易忽视的一环。客户端如果在长任务执行到一半时关闭了会话,你的工具回调可能还在傻乎乎地跑。务必在回调里监听extra.signal的中断事件,主动终止外部请求、释放连接、记录日志。能优雅退出就好过让整个进程被宿主强制干掉。

3. TypeScript 类型安全不是玄学:从 zod 推导到 tsconfig 的完整配置

MCP 自定义服务器这个主题,天然和 TypeScript 绑得很紧。官方 SDK 就是 TS 写的,而且类型定义相当完整,你要是用any糊弄,等于把这份红利全部丢掉了。但很多人的 TS 工程实践还停在“能编译过就行”的水平,结果开发阶段一时爽,工具一多、SDK 一升级,类型炸起来根本不知道从哪里救。

3.1 让类型从工具 schema 自动长出来,告别手写 interface

写工具时最爽的一点是:你声明了 zod schema,回调参数的 TS 类型就自动生成了。SDK 的类型系统会把z.object({...})的形状直接推断为回调入参的类型,不需要你手动定义 interface,更不需要写类型断言。

server.tool( "generateReport", { reportType: z.enum(["daily", "weekly", "monthly"]), fromDate: z.string().datetime(), toDate: z.string().datetime(), includeSummary: z.boolean().default(false), }, async (params) => { // params 自动推断为: // { // reportType: "daily" | "weekly" | "monthly"; // fromDate: string; // toDate: string; // includeSummary: boolean; // } const { reportType, fromDate, toDate, includeSummary } = params; // 如果你把 reportType 错写成别的字符串,编译期直接报错 } );

这样做收益最大的地方在于重构。工具数量增长到十几个以后,参数结构免不了要改。如果是手写 interface 配手写校验的套路,改一个字段可能牵连三四个文件;而 schema 作为唯一事实来源,改完 schema,所有依赖这个工具的地方都会在编译期告诉你哪里不匹配。凡是被“类型推不动”逼到写as any的,大概率是 schema 结构写得太别扭了,这时候该反思 schema,而不是靠断言糊弄过去。

3.2 tsconfig 的 module 陷阱:为什么 NodeNext 才能吃得开

MCP SDK 的包导出结构同时支持 ESM 和 CommonJS,但这不代表你随便配个module: "commonjs"就万事大吉。官方文档推荐 NodeNext,我实际用下来发现这不是推荐,而是必须,否则你会在导入路径上反复碰壁。

先看一个典型的tsconfig.json

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"] }

配套地,package.json里要声明"type": "module",这样所有.ts文件编译后自然就是 ESM,与 SDK 的 ESM 导出对齐。之前我看到很多人在社区里问“为什么import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'编译不过”,十有八九就是没开NodeNext,TypeScript 还在用旧的CommonJS解析规则找文件,结果找不到完整路径。

还有个容易被忽略的点:NodeNext 对文件名后缀要求极严格。你在相对导入的时候,前缀必须带.js结尾(比如import { helper } from "./utils/helper.js"),即使源文件是.ts。初次切换到 NodeNext 的人会疯狂报错,这是正常的,改规则很简单。

3.3 SDK 升级导致类型断裂的排查思路,以及 declare global 的用法

SDK 版本升级是类型问题的高发区。我遇到过最典型的场景:从 0.x 升级到 1.x,server.tool的注册方式变了,回调参数从(params)变成了(params, extra),返回类型里structuredContent的约束也变严格了。这种升级造成的类型断裂,错误信息往往很长、很吓人,但核心问题往往就那么几个。

我总结了一套快速排查路径,供参考:

  1. 看报错文件的 import 路径:SDK 升级后,部分导出路径变了,比如旧版@modelcontextprotocol/sdk/server/mcp.js和新版内部的类型导出位置不同,先确认所有import是否仍然有效。
  2. 看回调参数数量:如果报“函数参数类型不匹配”,大概率是新版回调变成了(args, extra)双参签名,你看一眼类型定义里ToolCallback的签名就清楚了。
  3. 看返回的content数组元素:新版对TextContent要求显式带type: "text"字段,如果你简写漏掉了type,类型检查会不通过。
  4. structuredContent的序列化约束:新版要求structuredContent是 JSON 可序列化的纯数据类型,DateMap这类对象放进去会直接报类型错误。

再来说说declare global。开发 MCP 服务器时,这个工具主要用于给 Node 全局对象扩展类型,比如你通过process.env读取自定义配置时,希望 TS 能感知到这些变量。直接在代码里写process.env.OPENAI_API_KEY,TS 认知里的process.env只有NodeJS.ProcessEnv的默认索引签名,能过但没有任何类型提示。你可以专门建一个env.d.ts

declare global { namespace NodeJS { interface ProcessEnv { OPENAI_API_KEY?: string; MCP_SERVER_PORT?: string; DATABASE_URL?: string; } } } export {};

这样全项目引用process.env.OPENAI_API_KEY时就有自动补全了。使用declare global的要点是:文件里要么有export要么有import,否则系统会把它当成全局脚本而不是模块,“类型声明只在本地生效”这个坑非常隐蔽,我第一次写的时候就被带偏过。搜索热度里“typescript 命名空间 declare global”排得很靠前,说明大家确实经常卡在这个地方。

4. 部署到生产环境:stdio、HTTP+SSE、Docker 与日志通道的选择

代码写完只是第一步,MCP 服务器真正进入“可用”状态,部署环节至少占一半工作量。而且这里有个和其他后端服务很不一样的地方:MCP 服务器有两种截然不同的传输模式,选错了,后面全部白搭。

4.1 先想清楚谁在调用你的服务:本地工具走 stdio,远程服务走流式 HTTP

MCP 的传输方式大致分两类:stdioHTTP+SSE(流式 HTTP)。这不是随你挑的,而是由客户端决定的。

如果你开发的是给 Claude Desktop、Cursor 这类本地客户端用的工具,那默认走的一定是 stdio。客户端会在本机拉起你的 Node 进程,通过标准输入输出进行 JSON-RPC 通信。这种情况下,部署形态就是保证进程能被客户端启动起来。Claude Desktop 的claude_desktop_config.json里配置的是commandargs,比如:

{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/dist/index.js"], "env": { "OPENAI_API_KEY": "sk-xxx" } } } }

这里面有几个极易踩的坑。路径问题:必须写绝对路径,因为客户端启动时的工作目录大概率不是你的项目目录。构建产物问题:要指向dist/index.js而不是src/index.ts,除非你专门用tsx这种运行时去跑 TS 源码,但那样得确保用户机器上有对应依赖。环境变量问题:从客户端拉起的进程,.env文件未必会自动加载,最好把关键配置显式写在env里,或者在代码里显式读取固定路径的配置文件。

反过来,如果你做的是一个需要被别人远程调用的服务,或者要部署在多台机器上的横向扩展服务,那就得走流式 HTTP。SDK 里对应的传输类是StreamableHTTPServerTransport,客户端通过 HTTP 长连接建立 SSE 通道实现通信。

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; const app = express(); app.use(express.json()); app.get("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on("close", () => transport.close()); await transport.handleRequest(req, res); }); app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); await transport.handleRequest(req, res); }); app.listen(3000);

走 HTTP 模式后,你面对的就是一套正经的 Web 服务部署:需要监听端口、处理鉴权、考虑并发和反向代理。MCP 协议本身没有内置鉴权,如果服务暴露在不可信网络,一定得自己在 HTTP 层加一层 Bearer Token 校验或者接入公司的统一认证。有次我把一个未鉴权的 MCP 服务暴露在测试环境,半小时内就收到了大量陌生 IP 的探测请求,这个教训很深刻。

两种传输模式的选型,我这里整理成了一张表,方便照着判断:

对比维度stdio 模式HTTP+SSE 模式
典型场景本地 AI 客户端调用远程服务、多人共享、集群部署
启动方式客户端拉起子进程常驻 Web 服务
鉴权难度无网络暴露,天然安全需要自行实现 API Key / OAuth
日志处理stdout 被占用,须用 stderr/文件可正常输出到 stdout 或日志框架
适合部署形态本地构建、直接运行Docker、Kubernetes、云服务器

4.2 Docker 化部署的关键点:多阶段构建与健康检查

很多教程把 Docker 部署描述得很简单,但实际上,直接把 Node 跑在容器里,再配一个端口映射,离“生产可用”还差得远。多阶段构建是我觉得投入产出比最高的一种方案:构建阶段安装全部依赖并编译 TS,运行阶段只保留编译产物和生产依赖,镜像体积小很多,部署也更快。

一个参考 Dockerfile:

FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM node:22-alpine ENV NODE_ENV=production WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist CMD ["node", "dist/index.js"]

健康检查是我见过最容易被跳过但又最重要的配置。如果是 HTTP 模式的 MCP 服务,暴露一个/healthz端点返回{"status":"ok"},容器编排系统靠它做探活非常方便。如果是 stdio 模式,容器化的意义其实很有限——因为本地客户端没法直接“spawn 一个容器里的进程”,stdio MCP 通常不适合往容器里塞。

那 stdio 模式要部署怎么办?我的经验是:保留最朴素的进程形态。用 systemd 或 pm2 守护进程,重点是保证它可以被稳定拉起来、异常退出后能自动重启、日志有地方可查。把一个大容器方案套在一个 stdio 工具上是过度设计,反而会引入很多无关的复杂性。

4.3 stdio 模式下的日志污染坑,以及 MCP Inspector 调试实战

这个坑我几乎每次带新手都会强调:stdio 模式下,stdout是 MCP 协议通信的专属通道,你打一个console.log就会把脏数据混进 JSON-RPC 消息流里。客户端解析不了,轻则工具调用失败,重则整个会话都崩掉。

所以你在工具回调里写任何“帮我看一眼”的调试日志,都绝不能走console.log。要打日志就得走console.error(stderr),或者直接写入文件。

// 错误示范:这条日志会被客户端当成 MCP 消息解析,直接破坏通信 console.log("工具被调用了,参数是", params); // 正确示范:stderr 和 stdout 是分开的,不会污染协议通道 console.error("工具被调用了,参数是", JSON.stringify(params)); // 更工程化的做法:集成 pino 或 winston,输出到文件 logger.info({ params }, "工具被调用了");

生产环境的日志建议直接落到文件里,并做好按天轮转。你可以用 pino 配pino/file输出到logs/mcp-server.log,也可以用 systemd 直接接管 stderr。总之,日志通道设计要在第一版就定好,不然排错的时候就等于瞎摸。

调试阶段有一个官方工具是必学的:MCP Inspector。运行方式很简单,stdio 模式用:

npx @modelcontextprotocol/inspector node dist/index.js

它会启动一个本地 Web 控制台,你在页面上能看到已注册的所有工具、手动调用工具并查看完整响应、观察通知消息。我每次做完一个工具都会先在 Inspector 里调一遍,确认返回内容和进度通知都符合预期,再交给客户端实际使用。这一步能省下大量“客户端为什么报错”的排查时间。

如果是 HTTP 模式,Inspector 也支持直接填 URL:

npx @modelcontextprotocol/inspector --transport http http://localhost:3000/mcp

部署完成后,不管用什么形态上线,我都建议先写一个最简客户端脚本,完整走一遍“连接→调用工具→拿到结果→断开”的链路,确认端到端通了,再宣布服务可用。

还有一个小技巧想分享:MCP 服务器如果部署在局域网服务器,用 nginx 反向代理 HTTP 服务时,必须记得关闭缓冲,否则 SSE 长连接会被 nginx 缓冲住,客户端半天收不到数据。

location /mcp { proxy_pass http://127.0.0.1:3000/mcp; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }

我最初就是因为没关proxy_buffering,本地用 Inspector 调得好好的,一上 nginx 就抽风,卡了将近一天才定位到是缓冲的问题。从那以后,字符串proxy_buffering off已经被我刻进 DNA 里了。

回看这段 MCP 服务器开发经历,最初看官方示例觉得不过如此,真正把一个工具从“最小原型”推到“生产可用”,中间的全是这些文档里不会细讲的坑。错误处理决定了下游模型能不能读懂你的工具,进度通知决定了长任务的使用体验,TypeScript 配置决定了项目能撑到多大规模而不失控,部署模式则决定了服务到底能被谁用起来。尤其是日志这条线,我建议所有刚起步的人先想明白:你的 stdout 是协议通道,调试信息不要跟协议数据抢车道。把这条理顺了,后面的问题大多都能靠日志快速定位;理顺不了,再简单的 bug 都能查到你怀疑人生。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 16:36:12

Android蓝牙协议栈bta_sys_sendmsg事件机制深度解析

简介&#xff1a;本资源是一份深度解析Android蓝牙协议栈核心消息机制的技术文档&#xff0c;专为初学蓝牙协议栈的开发者设计&#xff0c;解决阅读源码时因不熟悉bta_sys_sendmsg()调用链而无法追踪event发送路径的典型痛点。文档以设备搜索&#xff08;BTA_DM_API_SEARCH_EVT…

作者头像 李华
网站建设 2026/9/19 16:36:10

ADB安装与使用全指南:从环境配置到深度调试

1. ADB到底是什么&#xff1f;为什么它值得你花两小时认真学透 ADB&#xff0c;全称Android Debug Bridge&#xff0c;中文叫安卓调试桥。它不是某个App&#xff0c;也不是一个图形界面工具&#xff0c;而是一套运行在电脑端的命令行程序组合——包括adb client&#xff08;你…

作者头像 李华
网站建设 2026/9/19 16:35:40

HAR文件分析实战:从抓包到性能与安全诊断

1. HAR 文件不是“文档”&#xff0c;而是一份 HTTP 通信的完整录像带别人发来一个.har文件&#xff0c;第一反应往往是双击——结果弹出记事本&#xff0c;满屏密密麻麻的 JSON&#xff0c;缩进混乱、字段嵌套七八层、时间戳全是毫秒、headers 里混着 base64 编码的 cookie………

作者头像 李华
网站建设 2026/9/19 16:33:27

Atlas 300V 部署 YOLO 实战:从 ONNX 到 OM 的完整推理流程

从零开始在 Atlas 300V 上部署 YOLO&#xff1a;一张推理卡的实战手记手里正好有一张 Atlas 300V 24G&#xff0c;最近又把 YOLOv5/v8 在它上面完整跑了一遍流水线&#xff0c;中间踩了不少坑&#xff0c;也把 ASCEND 工具链的脾气摸了个七七八八。这篇文章就把整个流程掰开揉碎…

作者头像 李华
网站建设 2026/9/19 16:32:34

基于MATLAB/Simulink的空调温度控制系统建模与PID参数整定

简介&#xff1a;这份文档面向自动化、过程控制及相关专业的学生与工程技术人员&#xff0c;围绕冬季集中式空调温度控制系统展开建模与仿真&#xff0c;帮助读者掌握从对象建模到控制器参数整定的完整设计思路。资源包内仅含1个doc文件&#xff0c;约636KB&#xff0c;内容为课…

作者头像 李华