1. 一个独立开发者为什么要给自己的小产品接上 MCP server
先说清楚背景。我手上有一个自己维护的小产品,规模不大,属于那种“一个人写代码、一个人运维、一个人接客服”的状态。它提供一项具体的服务能力,过去用户想用它,得先打开网页、注册、手动填参数、点提交,整个链路对人是友好的,但对 AI 代理完全不友好——AI 代理根本不知道这个产品存在,更别说自动调用它。
这两年 AI 代理的形态变化很快。Claude、Cursor 这类工具已经不只是“帮你补全代码”,它们开始具备自主规划任务、调用外部工具、多步执行的能力。问题在于,代理能调用的工具,基本被限制在它自己生态里预置的那几个。你想让它用上你自己的服务,过去只有两条路:要么写一个插件塞进它的插件市场(审核周期长、门槛高),要么让用户手动把结果复制粘贴进去(体验稀碎)。
MCP(Model Context Protocol)改变的就是这件事。它本质上是一套标准化的“工具描述 + 调用”协议,让 AI 代理能够动态发现外部服务、理解每个工具能干什么、需要什么参数,然后自主决定要不要调用。你可以把它理解成给 AI 代理准备的一份“菜单”:代理拿到菜单,看到有“报价”这道菜,知道需要传什么原料,就会在合适的时候自己下单。
我给自己的小产品写了一个 MCP server,做完之后的效果是:用户在 Claude 或 Cursor 里说一句“帮我看看这个需求大概多少钱”,代理会自动发现我的报价工具、填入参数、拿到结果,整个过程用户不需要离开对话窗口。这就是标题里说的“AI 代理现在可以自动发现并报价它了”。
这篇文章适合两类人看。一类是手里有小产品、想让 AI 代理能调用它的独立开发者;另一类是想搞明白 MCP server 到底怎么落地、不想只看官方文档示例的工程师。我会把设计思路、协议细节、实操步骤、踩过的坑全部摊开讲,代码和配置都能直接抄。
2. MCP server 到底是什么:把“工具”翻译成代理能听懂的话
2.1 从“函数调用”到“协议发现”的思维转变
很多人第一次接触 MCP,会把它和传统的 function calling 混为一谈。两者确实像,但有一个关键区别:function calling 是你在代码里硬编码告诉模型“有这么个函数”,而 MCP 是让模型自己去问“你这儿有哪些函数”。
传统做法里,你写一个聊天应用,想让模型查天气,你得在 system prompt 或者 tools 参数里把get_weather(city)这个函数的签名写死。模型只能看到你预先塞给它的工具。换一个应用、换一个模型,这套描述就得重写一遍。
MCP 的思路是把“工具提供方”和“工具使用方”解耦。你的产品实现一个 MCP server,对外暴露一组标准接口:列出我有哪些工具、每个工具的参数 schema 是什么、调用后返回什么。任何支持 MCP 的客户端(Claude Desktop、Cursor、以及越来越多 IDE)都能连上你的 server,自动拉取这份“菜单”。
这个转变的意义在于:你的产品不再需要为每个 AI 平台单独适配,只要实现一次 MCP server,所有支持该协议的代理都能用。这就是为什么标题里用了“发现”这个词——发现是代理主动发起的,不是你推给它的。
2.2 MCP 的三种核心原语:tools、resources、prompts
MCP 协议里,server 可以对外提供三类东西,理解这三类的区别是设计的第一步。
- Tools(工具):代理可以主动调用的动作,比如“计算报价”“创建订单”“查询库存”。这是最核心的一类,也是我这次主要实现的部分。工具是有副作用的,代理调用前通常会请求用户确认。
- Resources(资源):代理可以读取的数据,比如“产品目录”“价格表”“文档”。资源是只读的,代理把它当作上下文来用,不会产生副作用。
- Prompts(提示模板):server 预置的提示词模板,用户可以主动选用。这一类用得相对少,适合把常见任务封装成“一键指令”。
我的小产品核心诉求是“让代理帮我报价”,所以重点放在 Tools 上。但我也顺手暴露了一个 Resources,把产品的服务说明和计费规则做成只读资源,这样代理在报价前能先读到规则,报出来的价格更靠谱。
提示:不要一上来就把所有功能都做成 tool。有副作用的、需要用户确认的,才适合做 tool;纯查询、纯展示的,优先考虑 resource。这个边界划清楚,代理的行为会稳定很多。
2.3 为什么选 MCP 而不是自己写一套 API 对接
有人会问:我直接写个 REST API,然后让代理通过 HTTP 调用不就行了?技术上可行,但有几个现实问题。
第一,发现机制。REST API 没有自描述能力,代理怎么知道你有/quote这个端点、需要传哪些字段?你得额外维护一份 OpenAPI 文档,还得指望代理能读懂。MCP 把这份描述内建到协议里,代理连上就能拿到结构化的工具列表。
第二,传输层适配。MCP 支持 stdio 和 HTTP 两种传输方式。stdio 模式下,server 就是一个本地进程,客户端通过标准输入输出和它通信,不需要开端口、不需要处理跨域、不需要考虑鉴权暴露。对独立开发者来说,这种“本地进程”模式部署成本极低。
第三,生态红利。Claude Desktop、Cursor 这些工具已经把 MCP 客户端做进去了,你实现 server 就能直接接入,不用自己写客户端适配层。这个红利在 2024 年下半年之后越来越明显。
我选 MCP,核心原因就是一次实现、多处可用,以及协议自带发现能力。这两点对一个小产品来说,性价比太高了。
3. 动手前的设计:报价工具该怎么切分
3.1 先想清楚代理会怎么用你的工具
写代码之前,我花了半天时间做一件事:模拟代理的使用路径。我把自己想象成一个 AI 代理,接到用户指令“帮我估算一下这个项目的报价”,我会怎么一步步走?
第一步,我得知道有这个报价能力。这对应 MCP 的tools/list,代理会拉取工具清单。
第二步,我得知道报价需要哪些输入。这对应每个 tool 的inputSchema,代理会读这个 schema 来决定向用户追问什么。
第三步,我调用工具,拿到结果。这对应tools/call。
第四步,如果报价依赖一些规则(比如不同服务档位的单价),我最好能先读到这些规则。这对应 resource。
把这四步想清楚,工具的设计就出来了:一个get_quote工具,输入是服务类型、工作量、紧急程度等参数,输出是价格区间和说明;一个pricing_rules资源,暴露计费规则。
3.2 参数设计:让代理“问得出来”
参数设计是 MCP server 里最容易被低估的环节。代理不是人,它不会“猜”你的意图。如果你的参数叫p1、p2,代理根本不知道要填什么。参数名和描述必须自解释。
我最初的版本里,报价工具只有一个参数spec,让代理把整个需求描述塞进去。结果代理经常传一段模糊的话,我的后端解析不出来,报价失败。后来我改成结构化参数:
| 参数名 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
| service_type | string(枚举) | 是 | 服务类型,如 web、mobile、data |
| workload | number | 是 | 预估工作量,单位为人天 |
| urgency | string(枚举) | 否 | 紧急程度,normal 或 rush,默认 normal |
| detail | string | 否 | 补充说明,用于人工复核 |
改完之后,代理的调用成功率明显上升。因为每个参数都有明确的类型和枚举值,代理知道该向用户追问什么,也知道自己填的值合不合法。
注意:枚举值一定要在 schema 里写全。代理看到
service_type是 string 但没有枚举约束时,可能会填“网站开发”“做个 App”这种自然语言,你的后端就得做模糊匹配,非常痛苦。把枚举写死,代理会乖乖从里面选。
3.3 返回值设计:给代理“能读懂”的结果
返回值同样重要。代理拿到结果后,往往要基于结果继续推理或向用户解释。如果你返回一个裸数字5000,代理不知道这是人民币还是美元、是总价还是单价。
我的做法是返回结构化的文本内容,MCP 的 tool 返回格式支持 content 数组,每项可以是 text、image 等类型。我返回一段结构清晰的文本:
报价结果 - 服务类型:web - 工作量:10 人天 - 紧急程度:normal - 预估价格:45000 - 55000 元 - 说明:最终价格以人工评估为准,此报价为初步估算这种格式代理读起来毫无压力,转述给用户时也不会丢信息。我试过返回纯 JSON,代理有时候会把 JSON 原样吐给用户,体验反而差。文本 + 结构化字段的组合,是目前最稳的返回方式。
4. 从零实现一个 MCP server:完整实操
4.1 环境准备与依赖选择
我用的是 Node.js 生态,因为 MCP 官方的 TypeScript SDK 成熟度最高,文档也最全。Python SDK 也有,但如果你要接入 Cursor 这类工具,TS 版本的兼容性更省心。
环境要求很简单:
- Node.js 18 以上(我用的是 20 LTS)
- npm 或 pnpm
- 一个支持 MCP 的客户端,我用 Claude Desktop 和 Cursor 各测了一遍
初始化项目:
mkdir my-mcp-server && cd my-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod@modelcontextprotocol/sdk是官方 SDK,zod用来定义参数 schema。这两个是核心依赖,其他都是可选的。
4.2 搭建 server 骨架
MCP server 的骨架非常短,核心就是创建一个 Server 实例,注册能力,然后连上传输层。
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const server = new Server( { name: "my-product-mcp", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } } ); // 后续在这里注册 tools 和 resources const transport = new StdioServerTransport(); await server.connect(transport);这段代码里有两个关键点。第一,capabilities声明了你的 server 支持哪些能力,客户端会据此决定要不要向你发对应的请求。第二,StdioServerTransport表示用标准输入输出通信,这是本地进程模式,客户端会以子进程方式启动你的 server。
提示:stdio 模式下,千万不要往 stdout 打印调试日志。stdout 是协议通信通道,你打印一行日志就可能破坏协议帧,导致客户端解析失败。调试信息一律走 stderr,用
console.error。
4.3 注册 tools/list:让代理看到你的工具
代理连上 server 后,第一件事就是拉工具清单。你需要处理ListToolsRequestSchema:
server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get_quote", description: "根据服务类型和工作量估算项目报价,返回价格区间", inputSchema: { type: "object", properties: { service_type: { type: "string", enum: ["web", "mobile", "data"], description: "服务类型:web 网站、mobile 移动端、data 数据服务", }, workload: { type: "number", description: "预估工作量,单位为人天,必须大于 0", }, urgency: { type: "string", enum: ["normal", "rush"], description: "紧急程度,默认 normal", }, detail: { type: "string", description: "补充说明,可选", }, }, required: ["service_type", "workload"], }, }, ], }; });description字段是给代理看的,写得越清楚,代理判断“什么时候该调用这个工具”就越准。我一开始把 description 写成“获取报价”,代理经常在用户只是随口问价格时也去调用。后来改成“根据服务类型和工作量估算项目报价,返回价格区间”,代理的调用时机就合理多了。
4.4 注册 tools/call:真正执行报价逻辑
工具被调用时,处理CallToolRequestSchema:
const PRICING = { web: 4500, mobile: 6000, data: 5500, }; server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== "get_quote") { throw new Error(`未知工具: ${request.params.name}`); } const { service_type, workload, urgency = "normal", detail } = request.params.arguments; if (!PRICING[service_type]) { return { content: [{ type: "text", text: `不支持的服务类型:${service_type}` }], isError: true, }; } if (typeof workload !== "number" || workload <= 0) { return { content: [{ type: "text", text: "工作量必须是大于 0 的数字" }], isError: true, }; } const base = PRICING[service_type] * workload; const multiplier = urgency === "rush" ? 1.5 : 1; const low = Math.round(base * multiplier * 0.9); const high = Math.round(base * multiplier * 1.1); const text = [ "报价结果", `- 服务类型:${service_type}`, `- 工作量:${workload} 人天`, `- 紧急程度:${urgency}`, `- 预估价格:${low} - ${high} 元`, detail ? `- 补充说明:${detail}` : "", "- 说明:最终价格以人工评估为准,此报价为初步估算", ] .filter(Boolean) .join("\n"); return { content: [{ type: "text", text }] }; });这里有几个实操细节值得说。第一,参数校验必须自己做。代理虽然会按 schema 填,但不保证 100% 合规,尤其是枚举值和数字范围。第二,错误要用isError: true返回,而不是抛异常。抛异常会让整个调用链断掉,代理拿不到可读的错误信息;用isError返回,代理能读到错误文本并向用户解释。第三,价格计算里的0.9和1.1是给报价留的浮动区间,实际业务里你可以换成更复杂的规则。
4.5 注册 resources:把计费规则暴露给代理
资源部分处理两个请求:列出资源和读取资源。
server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: "pricing://rules", name: "计费规则", description: "产品各服务类型的单价和计费说明", mimeType: "text/plain", }, ], }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (request.params.uri !== "pricing://rules") { throw new Error(`未知资源: ${request.params.uri}`); } return { contents: [ { uri: "pricing://rules", mimeType: "text/plain", text: "计费规则:web 4500 元/人天,mobile 6000 元/人天,data 5500 元/人天。紧急项目上浮 50%。", }, ], }; });资源的价值在于,代理在报价前可以先读规则,报出来的价格解释起来更有依据。我实测下来,暴露资源之后,代理向用户解释报价时会更详细,用户信任感也更强。
4.6 接入 Claude Desktop 和 Cursor
server 写完了,得让客户端能连上。Claude Desktop 的配置在claude_desktop_config.json里:
{ "mcpServers": { "my-product": { "command": "node", "args": ["/absolute/path/to/my-mcp-server/index.js"] } } }Cursor 的配置在设置里的 MCP 部分,格式类似,也是指定 command 和 args。路径一定要用绝对路径,相对路径在客户端启动子进程时经常找不到文件,这是新手最容易踩的坑。
配置完重启客户端,在对话里问一句“帮我估算一个 10 人天的网站项目报价”,代理应该会自动发现get_quote工具并调用。如果没反应,先检查 server 进程有没有正常启动,再看客户端日志里有没有 MCP 相关的报错。
5. 踩坑实录:那些文档里不会写的问题
5.1 代理不调用工具,或者乱调用
这是最常见的问题,原因通常有三个。
第一,工具描述太模糊。代理判断要不要调用工具,主要看 description。如果 description 写得太泛,代理要么不敢调,要么在不该调的时候调。解决办法是把 description 写成“什么场景下用、输入是什么、输出是什么”的完整句子。
第二,参数 schema 不完整。缺 required、缺 enum、缺 description,代理就不知道该怎么填,干脆不调。我建议每个参数都写 description,枚举值全部列全。
第三,工具太多。如果你一次暴露十几个工具,代理的选择困难症就犯了。我的经验是,单个 server 暴露的工具控制在 5 个以内,超过就考虑拆分或者合并。
5.2 stdio 模式下的日志灾难
前面提过一次,这里再强调。stdio 模式下,stdout 是协议通道。我一开始习惯性地用console.log打调试信息,结果客户端直接报“无法解析响应”。排查了半天才意识到是日志污染了协议流。
正确做法:所有调试信息走console.error,它会输出到 stderr,不影响协议。如果你需要更结构化的日志,可以写文件,但绝对不要碰 stdout。
5.3 中文参数导致的编码问题
我的产品面向中文用户,参数里难免有中文。早期版本里,代理传过来的中文在某些客户端下会出现乱码。排查后发现是传输层的编码没统一。解决办法是在 server 启动时显式设置编码,并且所有字符串处理都用 UTF-8。
process.stdin.setEncoding("utf8"); process.stdout.setEncoding("utf8");这两行加上之后,中文乱码问题基本消失。如果你也做中文场景,建议一开始就加上。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 客户端连不上 server | 路径错误或进程启动失败 | 检查绝对路径,手动运行 server 看报错 |
| 代理看不到工具 | capabilities 未声明或 list 处理异常 | 检查 capabilities 和 ListTools 返回值 |
| 代理不调用工具 | description 模糊或 schema 不完整 | 补全 description 和参数约束 |
| 调用返回乱码 | 编码未统一 | 显式设置 stdin/stdout 为 utf8 |
| 调用后客户端崩溃 | stdout 被日志污染 | 调试信息改用 console.error |
| 报价结果代理读不懂 | 返回格式过于原始 | 返回结构化文本而非裸数字 |
6. 上线之后:agent-to-agent commerce 的一点观察
6.1 代理之间的“自动报价”意味着什么
标题里我用了“agent-to-agent commerce”这个词,这不是噱头。当你的产品能被 AI 代理发现和调用,就意味着代理可以代表用户来和你的产品交互。用户说“帮我找个能做网站的人报个价”,代理去发现你的 MCP server、调用报价工具、拿到结果、比较几家、给出建议——整个过程用户只说了。一句话。
这对小产品来说是个机会。过去你需要在搜索引擎、应用商店、社交平台里抢曝光,现在多了一个入口:被 AI 代理发现。而这个入口的门槛,目前还很低,因为实现 MCP server 的独立开发者还不算多。
6.2 我实测下来的几个经验
第一,报价工具要能容错。代理传参不会永远完美,你的工具要能处理边界情况,返回可读的错误而不是崩溃。
第二,返回结果要“可转述”。代理拿到结果后往往要转述给用户,所以返回文本要结构清晰、信息完整,别让代理去猜。
第三,资源比工具更适合放规则。计费规则、服务说明这类只读内容,做成 resource 比塞进 tool 的返回值更合理,代理读起来也更自然。
第四,先跑通再优化。我第一版 server 只有 80 行代码,功能很糙,但能跑通完整链路。跑通之后再去优化参数设计、错误处理、返回格式,效率高得多。一上来就追求完美,很容易卡在细节里出不来。
6.3 后续可以扩展的方向
这个 server 目前只做了报价,后续我打算加几个方向。一是把“下单”做成 tool,让代理能直接创建订单(当然要加用户确认环节)。二是把产品文档做成 resource,让代理在回答用户问题时能引用。三是考虑加一个“查询订单状态”的 tool,让用户能通过代理查进度。
每加一个能力,都要重新想一遍“代理会怎么用”。这个思考过程比写代码本身更重要。工具设计得好,代理用起来顺;设计得差,代理要么不用,要么用错。
最后分享一个小技巧:在 server 里加一个“自检”工具,输入为空,返回 server 的版本、支持的工具列表、当前配置。调试的时候让代理调用一下,能快速确认 server 状态,比翻日志快得多。这个工具我每次接入新客户端都会先用一遍,省了不少排查时间。