写一个最小可运行的 MCP Server
系列入口:如何使用 Agent。前置理论:MCP 协议,配置见 客户端配置。
一、什么时候需要自己写
官方和社区已经有大量现成 MCP Server——数据库、浏览器、搜索、文件系统。多数常见需求不该重复造轮子。但当你要连接的是自己公司的内部系统——内部工单、私有 API、专有数据——且没有现成 Server 时,自己写一个就是顺理成章的选择。
自己写还有另一层价值:它把「Agent 能做什么」收敛进一个你完全掌控、可评审、可测试的边界里。与其给 Agent 一把能跑任意命令的 shell,不如只暴露几个语义清晰、范围受限的工具。这往往比想象中简单,核心骨架只有几十行。
二、最小可运行示例
下面是一个完整但精简的示例:一个「在指定目录内按文件名查找」的 Tool。它包含了你写任何 Server 都需要的全部结构。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readdirSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
const server = new McpServer({ name: "file-finder", version: "1.0.0" });
// 允许访问的根目录,超出范围一律拒绝——安全边界写在代码里
const ALLOWED_ROOT = resolve(process.env.ALLOWED_ROOT || "./data");
server.tool(
"find_files",
// description:Agent 靠它判断「什么时候该调用」,要写清场景与边界
"在允许目录内按关键词查找文件。用于需要定位包含某关键词的文件路径时;仅搜索 ALLOWED_ROOT 范围内。",
{ keyword: z.string().min(1).describe("文件名要匹配的关键词"), recursive: z.boolean().default(false) },
async ({ keyword, recursive }) => {
const hits = [];
const walk = (dir, depth) => {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
// 关键的范围校验:防止路径越出允许根目录
if (!resolve(full).startsWith(ALLOWED_ROOT + sep)) continue;
const st = statSync(full);
if (st.isDirectory()) {
if (recursive && depth < 5) walk(full, depth + 1);
} else if (name.includes(keyword)) {
hits.push(full);
}
}
};
walk(ALLOWED_ROOT, 0);
return { content: [{ type: "text", text: JSON.stringify({ count: hits.length, hits }, null, 2) }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);这个例子体现了三个关键设计:description 说清用途与边界、inputSchema(zod)声明参数、handler 内部强制做范围校验。 三者缺一,工具要么不被正确调用,要么可能被滥用。
三、三件事决定工具好不好用
3.1 description:Agent 的「调用决策依据」
Agent 是否调用你的工具,几乎完全取决于 description。写得含糊(「处理文件」),Agent 不知道何时用;写得具体(「在允许目录内按关键词查找文件,用于定位文件路径」),它才能在合适场景选中。把它当成写给 Agent 的使用说明,包含「做什么、何时用、边界在哪」。
3.2 inputSchema:让参数传递可靠
用 JSON Schema(或 SDK 的 zod)声明每个参数的类型、是否必填、约束和说明。Schema 越完整,Agent 传错参数的概率越低,你也能在 handler 收到合法、经过校验的输入,而不是自己再防御一堆脏数据。
3.3 返回与错误:规范、可被解析
返回内容放进标准的 content 数组,文本用 JSON 或清晰结构,方便 Agent 理解和继续使用。出错时返回标准错误,而不是让进程 crash。一个长期崩溃的 Server 会让 Agent 反复重试,既浪费又得不到结果。
四、另外两种原语:Resources 和 Prompts
Tools 是「执行动作」,但 MCP 还有两种原语,知道何时用能让设计更贴切。
Resources 用于「读取数据」——把一份可被引用的只读内容暴露出来,比如某个配置、文档、记录。适合那些更像「提供上下文」而非「执行操作」的场景。Prompts 用于「提供预设模板」——把一段经过设计的提示或参数化工作流封装起来复用。
简单判断:要它动手、产生副作用,用 Tool;要它提供一份数据供阅读,用 Resource;要固化一个可复用的提示套路,用 Prompt。很多新手把所有东西都做成 Tool,但一个纯读取需求用 Resource 表达,语义更准确、也更安全。
五、连接内部系统
MCP Server 本质是个普通进程,因此它可以用任何方式访问你的内部系统:调用内部 HTTP API、查数据库、读消息队列。典型做法是把内部系统的地址和令牌通过环境变量注入,Server 在 handler 里代为访问,再把结果以工具形式返回给 Agent。
这样做的最大好处是收口:Agent 不直接持有内部系统的高权限凭据,也不直接接触网络细节;它只能调用你暴露的几个有限工具。凭据存在 Server 运行环境里,权限范围、可访问的接口、返回字段都由你决定。这正是「最小授权」在架构层的落地。
五点五、一个「写了但 Agent 就是不调用」的工具
新手最有挫败感的经历,往往不是代码报错,而是工具明明注册成功、Agent 却从不调用它,或总在错误的场景调用。问题几乎都出在 description 上。
设想你写了一个内部库存查询工具,为了省事把 description 写成「库存工具」。Agent 面对「帮我看看上周哪些商品补货了」这样的需求时,完全没有依据把这个问题和「库存工具」关联起来——它不知道这工具是查实时库存、还是查补货历史,也不知道要不要时间参数。于是它要么干脆不调用,要么凭猜测乱传参数。反过来,当你把 description 改成「查询商品库存与近期补货记录;当需要知道某商品当前库存数量或指定时间范围内的补货明细时使用;需要商品 SKU,时间范围可选」,同一个工具立刻就被正确调用了。
这背后的机理值得理解:Agent 并不是「读懂」了你工具的内部实现,它在选择工具时能看到的几乎只有名称和描述。 内部代码再精巧,对它的调用决策都不可见。description 就是你和它之间唯一的「使用说明书」。写它时可以套用一个稳定句式:「做什么 + 什么时候用 + 需要什么关键参数 + 有什么边界」。把这四点写全,工具被选中的准确率会有质的提升。
还要避免另一个极端:为了让工具「更可能被调用」而把描述写得无所不包、夸大范围。这会导致它在不该用的场景被误触发。准确、具体、有边界,永远比「什么都能干」更重要——你要的是在正确场景被调用,而不是被频繁调用。
五点六、返回值设计:Agent 也是「读者」
工具能被调用只是第一步,返回什么、怎么返回,决定了 Agent 能否用好结果。这里有个容易被忽视的视角:返回内容是给下一个智能体阅读和继续推理的,因此它同样需要清晰、克制、结构化。
最常见的坏返回,是把一大坨原始数据不加裁剪地丢回去:一次查询返回几千行、每行几十个字段,既占用大量上下文,又把真正关键的信息淹没,Agent 反而容易读错或遗漏。好的设计是在工具层就做合理裁剪和汇总——默认返回条目数设上限、只留完成任务所需字段、必要时同时给出聚合信息(总数、是否被截断),并在结果被截断时明确提示「还有更多,请缩小范围」。
另一个要点是错误也要可读。当参数非法、依赖的内部系统超时、或范围越界时,返回一句结构化、说明「为什么失败、可以怎么调整」的信息,比抛出一个让进程崩溃的异常有用得多。崩溃会让 Agent 误以为是偶发问题而反复重试;而一个清晰的「参数 start 不能为负」能让它立即修正参数、继续完成任务。把 Agent 当成一个会根据你反馈调整行为的协作者,你的返回值就会自然地从「给机器的数据」变成「可对话的应答」。
五点七、可测试性:别让每次验证都要经过 Agent
很多人自建 Server 时陷入一个低效循环:改一点代码,就注册到客户端、开个会话、打字让 Agent 调用、再肉眼看结果。这个反馈环又慢又不稳定,因为中间还隔着模型的不确定性——出了问题你都分不清是工具错了、还是 Agent 没调对。
更专业的做法是把核心逻辑和 MCP 协议层分开:真正干活的那个函数(接收普通参数、返回普通数据)独立成一个可直接调用、可写测试的纯逻辑模块,MCP handler 只是薄薄的一层包装。这样你可以像测试普通函数一样,用单元测试覆盖各种输入:正常关键词、空结果、递归、超长路径、越界路径、内部系统超时。当核心逻辑被测试充分固定后,再接到协议层,问题边界会清晰得多。
这个分层还带来长期好处:哪天你想把同一能力换成另一种协议、或在别处复用,核心逻辑无需改动;当工具行为出现争议时,跑一遍测试就能立刻判断是不是它的问题。先让工具作为一段普通代码被信任,再让它作为一个 MCP 能力被使用——这是把自建 Server 从「能跑的脚本」提升为「可维护部件」的关键一步。
五点八、长期运行的 Server:内存、连接与超时
很多示例只演示「一次性调用」,但真实的 stdio Server 是被客户端长期持有的常驻进程,它的健壮性问题要到运行一段时间后才暴露,值得提前防范。
第一类是资源泄漏。如果每次调用都新建一个到内部系统的连接、却不释放,或把结果不断累积进某个全局数组,进程内存会随调用次数缓慢增长,几小时或几天后被系统杀掉,表现为「用着用着就连不上了」。对策是用完即释放连接、避免在全局存放不断增长的数据。第二类是无边界等待。当内部系统响应变慢,若你的调用不设超时,handler 可能一直挂住,占用连接、拖住 Agent;加上明确超时和失败返回,才能让异常快速显形而不是堆积。
第三类是并发假设。多个工具调用可能在短时间内发生,要确认你访问的共享状态或内部接口在并发下是安全的,只读场景通常简单,涉及写入则要考虑顺序与事务。把 Server 当成一个需要长期稳定运行的服务来对待——关注内存曲线、设置超时、处理好并发和错误,而不仅是「在我的机器上跑通一次」。当它能安静地常驻数天不出异常,这个自建能力才算真正可依赖。
五点九、版本演进:怎么给工具加功能而不破坏既有用法
Server 一旦被用起来,就进入了需要维护的阶段,如何平滑演进是个现实问题。
最容易引发事故的,是直接修改既有工具的参数含义或返回结构。如果某个工具原本返回字段 A,你在升级时把它改名或改变语义,依赖旧结构的工作流和 Agent 预期会突然失效,而这种失败往往要到实际使用时才被发现。更稳妥的做法是把工具契约当作需要尊重的接口:新增可选参数、追加新字段通常是兼容的;而改变既有语义、删除字段则属于破坏性变更,应通过新工具或明确的版本迁移来完成。
演进时还要守住安全默认值不被顺手放宽——为了某个新功能而把原本受限的根目录、权限扩大,会悄悄削弱早期精心设计的护栏。每加一个能力,都重新问一遍它需要的最小权限和范围。配合前面讲的分层与测试,你可以在改动后快速回归既有工具是否仍然正常。用「默认向后兼容、破坏性变更显式化、安全默认不回退」三条纪律来管理演进,你的 Server 才能在功能增长的同时,持续稳定、不埋暗雷。
五点十、什么时候不该自己写:先问「真的需要吗」
在结束「怎么写」之前,值得反过来泼一盆冷水:自建 Server 是一项会长期存在的维护负担,动笔前应先确认它确实是最优解,而不是因为「写一个很酷」。
先做一次充分的检索:你要的能力是否已有成熟、可信的现成 Server?很多常见系统都有官方或高口碑实现,直接接入通常比自己维护一个功能重叠的版本更省心。其次评估替代路径:同一个目标,能否通过一个更窄的命令、一个只读脚本、甚至一次人工导出数据来完成?如果这是一个一年只用两次的低频需求,为它养一个常驻服务、持续跟进协议升级,投入产出往往不划算。
真正适合自建的信号是:需求反复出现、面向内部专有系统且无现成方案、并且你需要对能力边界和凭据有强掌控。把自建 Server 留给这类高价值、高定制的场景,对其余需求克制地选择现成工具或更轻的办法。这种「先问该不该、再谈怎么做」的判断,和 Server 本身的技术质量同样重要——它让你把有限的维护精力,花在真正值得长期拥有的能力上,而不是制造一堆需要照看、却很少被使用的服务。
六、安全边界清单
- 限定范围:文件类工具强制校验路径不越出允许根目录,防目录穿越。
- 只读优先:第一版尽量只暴露只读工具;写操作单独、谨慎开放,并考虑事务与幂等。
- 不暴露密钥:凭据只从环境读取,绝不出现在返回内容或 description 里。
- 校验一切输入:把 Agent 传入的参数当作不可信数据,依赖 Schema 并在 handler 再校验。
- 控制代价:为递归深度、返回条数、超时设上限,避免一次调用拖垮服务。
- 长期运行的健壮性:注意内存与连接泄漏,错误要被捕获而不是中断进程。
七、注册并实际验证
写完后用 客户端配置 的方式注册,例如命令行:
ALLOWED_ROOT=/data/app claude mcp add file-finder -- node file-finder.mjs
claude mcp list然后让 Agent 实际调用一次,观察返回;也可以直接用终端手动启动,确认进程不报错。调试日志写到 stderr,避免污染 stdout 上的 JSON-RPC。当一个只读调用稳定返回预期结果,再考虑增加更复杂或带写入的工具。
八、常见问题
Q:一定要用官方 SDK 吗? 不必须,但 SDK 帮你处理了握手、JSON-RPC、Schema 校验等大量细节,自己裸写很容易在协议细节上出错,除非有特殊理由,建议用 SDK。
Q:怎么调试看不到任何输出? stdio 服务通过 stdout 收发协议,普通 console.log 会破坏消息。用 stderr 输出调试信息,或单独跑测试脚本直接调用 handler。
Q:如何让团队都用上我写的 Server? 把代码和注册配置纳入仓库(凭据用环境变量),其他人拉取后安装依赖即可;要更进一步共享常驻实例,可部署为远程 HTTP MCP 服务。
Q:工具越来越多,Server 会不会变得很乱? 会,所以要按业务能力分组、每个工具职责单一,避免一个工具通过参数承担十几种动作。当工具数量增长,优先复用清晰的命名和返回结构,并为每组能力写测试,把复杂度控制在可维护范围内。
Q:能不能在一个 Server 里同时用 Tools、Resources、Prompts? 可以,协议允许共存。按语义选择:执行动作用 Tool、只读数据用 Resource、可复用提示用 Prompt;不要为了统一而把所有能力都塞进 Tool。
总结
写 MCP Server 就是四步:创建服务、定义工具、实现处理、启动传输;而工具好不好用取决于写清「做什么/何时用/边界」的 description、完整的 inputSchema,以及规范的返回与错误。纯读取用 Resources、复用套路用 Prompts;连接内部系统时让 Server 收口凭据、限定范围、只读优先,并把 Agent 输入当不可信数据校验,你就能用几十行代码安全地把专有能力变成 Agent 可调用的工具。