☰
从零编写MCP Server:让AI代理自动发现并调用你的产品工具
2026/10/2 15:41:40 网站建设 项目流程

上个月,我给我的小产品补上了一个 MCP server。上线第二天,就有朋友用支持 MCP 的 AI 代理,在没有我任何引导的情况下,自己发现了产品里有哪些套餐、调用了报价工具、最后弹出一份像模像样的报价单。那一刻我才真正意识到,MCP server 不只是给 AI 加一个接口,而是让产品从一个"需要人类读文档才能用"的东西,变成了"AI 代理可以自动发现并操作"的东西。

这里说的 MCP,全称是 Model Context Protocol,可以理解成给 AI 代理开一个统一格式的"工具箱入口"。以前我做对外能力,习惯是先写一套 REST API,再补一份详细的 OpenAPI 文档。但在 AI 代理眼里,一堆 HTTP 接口加一份文档约等于不存在——它不会主动去翻文档,也不擅长把流程拆成五六个请求再拼装结果。MCP server 把能力变成它可以枚举、能够调用、还能看懂参数说明的工具,这才是"能被 AI 发现"的关键。这篇文章就把我这次从零到一写 MCP server 的全过程、踩过的坑和最终效果完整记录下来,给同样想让自己小产品融入 AI 生态的独立开发者做个参考。

1. 事情的起因:产品有 API,但 AI 代理根本不看你的文档

1.1 我的小产品:一个在线报价引擎

先交代一下背景。我维护的小产品是一个面向小团队的在线上报价引擎——用户在后台配置好套餐、用量单价和折扣规则,然后通过我提供的一小套 REST API,把报价能力嵌入到自己的官网或者 CRM 系统里。产品本身不大,用户量也不算多,但一直有稳定的付费客户,也有一些渠道合作伙伴在帮忙推广。

这个产品天然和"报价"这个词强相关:它有明确的价格体系,有计算逻辑,有套餐阶梯,有超量计费规则。以前客户接入流程是:注册账号、看文档、调接口、自己拼 UI。每一步都需要一个会写代码的人,而且这个人必须愿意读文档。

1.2 朋友带着 AI 代理来"试用"产品的翻车现场

上个月有个朋友跟我说,他们团队最近全在用支持 MCP 的 AI 代理处理日常事务,问我这个产品有没有办法让代理直接试用。我当时的反应是:有 API 呀,文档也写得挺全,让代理调用不就行了?

结果他丢给我一段对话记录。代理被要求"了解一下这个报价产品的价格体系,并给出一个试用场景下的报价",它压根没有去读我的 API 文档,而是自己编了一套价格——套餐名是编的,单价是编的,折扣规则也是编的,最后生成的报价单和真实产品差了十万八千里。

这个翻车现场让我意识到一个扎心的事实:REST API + 文档,是给"会读文档的程序员"准备的;而 AI 代理是一个"非常聪明但非常懒的新员工"。它不会主动去翻你几十页的文档,也不擅长把一个流程拆成五六个 HTTP 请求再拼装结果。你要么把能力包装成它能直接看见、直接调用的形式,要么它就按照自己的想象工作。

1.3 为什么 MCP 能解决"可发现性"

MCP(Model Context Protocol)做的事情其实很朴素:定义了一套统一的工具描述格式和调用协议,让不同 AI 客户端都能通过tools/list枚举你的能力、通过tools/call调用你的能力。客户端在启动会话时会把你的工具清单(名称、描述、参数 schema)注入模型的上下文,模型据此判断"这个产品有什么、怎么用"。

这一两年 MCP 被客户端收编的速度肉眼可见:主流桌面客户端、IDE(比如 Trae、Cursor 这类)、各种 agent 框架都原生支持;市面上连传统安全测试工具、浏览器、数据库客户端都有人在做对应的 MCP server。也就是说,只要我按协议把能力暴露出来,用户现有的 AI 代理就能直接"发现"我的小产品,不需要我再为每个客户端写定制集成。

而且 MCP 是客户端侧的协议,这意味着即使用户跑的是本地模型,只要他的客户端支持 MCP,一样能调用我的工具。这对于小产品来说是一个巨大的红利:你不需要等某个大模型厂商来收录你,只需要做好自己的 server。

2. 动手之前,先把 MCP 这四个概念理顺

2.1 Tools、Resources、Prompts 三件套

MCP 规范里,server 对外暴露的核心入口有三个:

  • Tools:让模型执行动作的入口,比如"计算报价""创建订单"。每个 tool 需要name、description、inputSchema。这是我最关心的部分。
  • Resources:给模型提供上下文的数据,比如"套餐列表""当前折扣规则"。客户端可以把它当作可读取的内容注入上下文。
  • Prompts:预置的交互模板,比如"生成一份标准报价单的引导流程"。

对我的报价场景来说,Tools 是核心,Resources 可以用来把价格表直接喂给模型,Prompts 是锦上添花。第一版我只实现了 Tools,先把核心闭环跑通,再考虑后面两个。

2.2 stdio、SSE 与 Streamable HTTP 三种传输方式

MCP 的消息传输主要有三种:

  • stdio:本地进程模型,客户端直接启动你的进程,通过标准输入输出传 JSON-RPC 消息。适合本地工具、桌面客户端集成,调试也最方便。
  • SSE:通过 HTTP 长连接推送消息,适合远程部署,但协议稍复杂。
  • Streamable HTTP:更新的协议,统一了请求/响应和流式消息,是目前官方推荐走向。

我第一版只实现了 stdio,目的是快速跑通闭环;远程部署场景后面再用 Streamable HTTP 补。这里也回答了很多人在"本地启动 mcp server 教程"里看到的困惑:为什么配置文件里写的是命令和参数,而不是 URL?因为 stdio 模式下,客户端就是你的进程管理器。

2.3 底层是 JSON-RPC 2.0

MCP 消息本质上是 JSON-RPC 2.0,方法名像tools/list、tools/call、notifications/initialized。客户端初始化时会握手确认协议版本,常见的有2024-11-05、2025-03-26这类日期版本。

如果直接用官方 SDK,握手和版本协商都会被处理掉。但如果你出于好奇自己手写协议层,要注意初始化时序:先发initialize,等响应后发notifications/initialized,之后才能调工具。顺序错了,客户端会直接报握手失败。我第一次手写协议测试时就卡在这里,后来老老实实换回 SDK。

3. 代码落地:SDK 选型、服务骨架与"让 AI 认得出你"的工具描述

3.1 TypeScript 还是 Python

选型上没有纠结太久。我的报价引擎本身是 Node 写的,所以选了 TypeScript 官方 SDK(@modelcontextprotocol/sdk)。好处是内部逻辑可以复用,不用跨语言;TS 的类型系统和 zod 配合,定义inputSchema非常顺手,编译期就能挡住不少参数拼写错误。

如果你的产品是 Python 栈,直接用mcpPython SDK 就好,两边协议完全一致。选型主要看团队技术栈,MCP 协议本身没有语言偏好。

3.2 最小可跑的 server 骨架

下面是完整的第一版骨架,核心代码其实不超过 80 行:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: "quote-service", version: "0.1.0", }); server.registerTool( "list_plans", { title: "列出可用套餐", description: "返回当前在售的全部套餐,包含套餐 ID、名称、固定月费、包含用量额度与超量单价。AI 在计算报价前必须先调用本工具,从中选择 plan_id,禁止凭空编造套餐 ID。", inputSchema: { type: "object", properties: {}, required: [], }, }, async () => { const plans = await getActivePlans(); return { content: [{ type: "text", text: JSON.stringify(plans) }], }; } ); server.registerTool( "calculate_quote", { title: "计算报价", description: "根据套餐、预估用量和计费周期计算每月费用。plan_id 必须来自 list_plans 的返回结果;usage_amount 是月预估用量(整数);billing_cycle 只能是 monthly 或 annual。", inputSchema: { type: "object", properties: { plan_id: { type: "string", description: "套餐 ID" }, usage_amount: { type: "number", description: "预估月用量" }, billing_cycle: { type: "string", enum: ["monthly", "annual"] }, }, required: ["plan_id", "usage_amount", "billing_cycle"], }, }, async ({ plan_id, usage_amount, billing_cycle }) => { const quote = await pricingEngine.calculate({ plan_id, usage_amount, billing_cycle }); return { content: [{ type: "text", text: JSON.stringify(quote) }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport);

注意一点:SDK 版本更迭比较快,早期版本用的是server.tool(name, description, paramsSchema, handler),新版推荐server.registerTool这种对象式写法。如果你的版本报错,说明 API 变了,去查对应版本的文档即可,协议本身没有变。

3.3 工具命名:让代理一眼看懂"这是干什么的"

工具命名我踩过一次坑。第一版我把工具命名为plans、quote_calc、make_order,结果代理经常搞混。后来统一成动词_名词的格式:list_plans、calculate_quote、create_quote。这样工具清单被注入模型上下文时,模型一眼就能理解每个工具的用途。

命名是"可发现性"的第一步,但更重要的是描述。模型在选择工具时,主要依据就是description字段。它没有看过你的产品,没有读过你的文档,它对你的全部认知都来自这段描述和参数 schema。

3.4 "发现"机制里最容易忽略的部分:描述即文档

很多 MCP server 作者把精力花在实现逻辑上,描述只写一句"查询报价"就完事。结果就是代理虽然枚举到了工具,却不知道怎么用,只好瞎猜参数,猜完又是invalid params,来回折腾几次后代理干脆放弃了。

我后来把description当成"给一个极其较真、且不会追问的新员工写操作手册"。会有三部分内容:工具是干什么的、调用前需要做什么、参数从哪里来。比如calculate_quote的描述里我明确写了"plan_id 必须来自 list_plans 返回结果"。没有这句话,代理很可能会编一个plan_id传进来。

工具数量也要克制。客户端每次会话都会枚举全部工具并注入上下文,工具越多,上下文占用越大,模型选错的概率也越高。我第一版做了 8 个工具,后来精简到 4 个,把几个低频功能合并了,实测选工具准确率明显上升。

4. 联调全流程:从 MCP Inspector 到真实 AI 代理

4.1 先用 Inspector 过一遍协议

写完代码第一件事,不是直接接入客户端,而是用官方调试工具 MCP Inspector 验证协议正确性。命令很简单:

npx @modelcontextprotocol/inspector node dist/index.js

Inspector 会启动一个本地 Web 面板,你可以在里面手动发tools/list、tools/call请求,也可以自定义参数。我第一次跑的时候,list_plans返回正常,但calculate_quote传参时因为 schema 里usage_amount写了number,测试时传了小数,后端计算直接报错。Inspector 能清楚地看到请求和响应全文,定位这类问题比在客户端里猜快得多。

我强烈建议任何 MCP server 上线前都先用 Inspector 把每个工具至少手动调用一遍。它相当于接口测试工具,能帮你过滤掉最基础的协议层问题。

4.2 接入真实客户端跑一遍完整流程

Inspector 验证通过后,接入真实客户端。以常见的桌面客户端为例,在它的 MCP 配置文件里加上:

{ "mcpServers": { "quote-service": { "command": "node", "args": ["/absolute/path/to/dist/index.js"], "env": { "MCP_LOG_FILE": "/var/log/mcp-quote.log", "MCP_LOG_LEVEL": "debug" } } } }

重启客户端,看到工具列表里出现了quote-service的四个工具,就算接入成功。然后我做了两个测试:一是让代理直接问"你们有什么套餐",二是让代理"根据季度用量 300 万次请求,推荐一个套餐并报价"。第二个测试才是真正检验"可发现性"的——它需要代理先调用list_plans,再根据返回结果调用calculate_quote,中间还要自己做推理。

实测第一轮就翻车了,但不是协议问题,而是工具描述问题。代理调了list_plans后,把返回的 JSON 原样贴给了用户,完全没做报价动作。后来我在描述里加了"AI 应根据用户需求主动推荐一个最匹配的套餐,并调用 calculate_quote 生成报价",第二轮就正常了。

4.3 stdio 项目里如何做自定义日志管理

这是"自定义日志管理"热词背后最容易被忽略的坑:stdio 模式下,标准输出是协议通道,绝对不能用console.log打日志。一旦打了,JSON-RPC 消息流就被污染,客户端会解析失败,表现千奇百怪——有的直接报连接错误,有的消息错乱。

我的做法是文件日志加 stderr 双写:

import { appendFileSync } from "node:fs"; const LOG_FILE = process.env.MCP_LOG_FILE ?? "/tmp/mcp-quote.log"; export function log(level: string, message: string, meta?: unknown) { const line = `${new Date().toISOString()} [${level}] ${message} ${meta ? JSON.stringify(meta) : ""}`; appendFileSync(LOG_FILE, line + "\n"); process.stderr.write(line + "\n"); }

process.stderr.write不会污染协议通道,所以可以在终端实时看;文件日志则方便事后回溯。每条日志我都会带上时间戳和上下文,排查问题时能完整还原一次工具调用的生命周期:收到请求、校验参数、查询套餐、计算价格、返回结果。

4.4 一次失败调用的完整排查过程

说一个真实案例。上线第二天,用户反馈"AI 代理报价时报错"。我的第一反应是看日志文件,日志显示代理调calculate_quote时传了一个plan_id: "premium_2020",但系统里根本没有这个 ID,后端抛了plan not found。

这个错误本身不复杂,但值得复盘的是根因:为什么代理会编造一个不存在的 ID?因为它没有先调list_plans。为什么没调?因为calculate_quote的描述里只写了"根据套餐计算报价",没写"plan_id 必须来自 list_plans"。代理不知道有这个前置依赖,就按照自己的理解编了一个。

修复方式不是改代码逻辑,而是改描述,加上那句"plan_id 必须来自 list_plans 的返回结果,禁止凭空编造"。改完重启,再让同样的问题跑一遍,这次代理老老实实先枚举套餐,再选 ID 报价。这是我这次项目里最有价值的一条经验:很多看似是 AI 智障的问题,其实是你的工具描述没有把隐含的调用规则说出来。

5. 实战中踩过的坑,以及我总结的边界规则

5.1 描述质量直接决定 AI 是否"认识"你的工具

第一个坑前面已经说了,描述不能写得像函数注释。我再给一个对比,同样是create_quote这个工具:

  • 差:创建报价
  • 好:根据已选套餐、用量和计费周期创建正式报价单。调用前必须先通过 calculate_quote 获取报价明细,再传入确认后的参数。返回报价单 ID 和下载链接。

区别在于,后者告诉代理"这个工具依赖什么、要有前置动作、返回里有什么"。模型看到好的描述,就能自己组织出一条正确的调用链;看到差的描述,就只能瞎猜。

5.2 schema 的松紧平衡与错误返回

schema 太松,代理就会传奇怪的值进来;schema 太紧,代理稍微表述变个形就持续报invalid params。我的经验是:能用enum的用enum,能用格式约束的用格式约束,但不要加无关紧要的必填项。比如billing_cycle我用enum: ["monthly", "annual"],代理就得二选一,不会传 "年付"、"1 年"这种变体。

错误返回也要设计。不要直接把异常堆栈返回给客户端,模型读到堆栈只会懵。我会在 handler 里捕获业务错误,返回一段模型能读懂的文本:

{ "content": [ { "type": "text", "text": "plan_id premium_2020 不存在。当前可用套餐 ID 为:starter、growth、enterprise。请先从 list_plans 获取最新列表。" } ] }

这样代理看到错误后,能自行纠正参数。JSON-RPC 错误码我整理成了一张表,放在项目文档里:

错误码含义我的使用场景
-32601method not found客户端请求了未注册的方法
-32602invalid params参数校验失败
-32603internal error未捕获的异常
-32000server error(自定义)业务错误,如套餐不存在

业务错误尽量用 -32000 加可读文本返回,不要把底层异常细节暴露出去。

5.3 状态、幂等与长任务

MCP 的每次工具调用从 server 角度看都是无状态的,但业务往往需要状态。比如create_quote如果被代理重试两次,就可能产生两条重复报价单。我的解决办法是支持幂等键:客户端可以传idempotency_key,server 端同一个 key 只处理一次,重复请求直接返回第一次的结果。

长耗时操作也值得警惕。很多客户端对工具调用有超时限制,一个计算超过 60 秒的工具基本会超时。我的报价计算虽然不慢,但我把"生成完整报价 PDF"这种重操作拆成了两步:先create_quote生成记录,再get_quote_status查询生成状态。这种异步任务模式比让代理傻等一个超时调用靠谱得多。

5.4 安全与成本:别让代理变成你的免费接口搬运工

MCP server 暴露给代理后,代理就成了一个自动化的用户。你不能假设代理只会按你预期的方式调用。我在服务端做了三件事:

  • 参数二次校验:所有工具入口都做完整的业务校验,不信任代理传来的任何值。
  • 调用频率限制:同一个会话对calculate_quote的调用频率做了限制,防止代理在推理过程中反复试错打爆后端。
  • 日志脱敏:报价场景会涉及客户名称、联系方式,日志里打码处理。

如果将来用 Streamable HTTP 跑远程 MCP server,认证是必须的,至少加一层 bearer token,理想情况是走 OAuth。远程暴露的攻击面比本地 stdio 大很多,这条不能省。

6. 上线后的变化,以及我接下来打算做的事

6.1 实测效果:代理真的开始帮我报价了

上线一周后,我让朋友再用他们的 AI 代理来试。这次代理在收到指令后,自己完成了一整套动作:list_plans拉取套餐、根据需求推理选择套餐、calculate_quote计算价格、最后用create_quote生成报价单。整个过程不到两分钟,和我人工操作的速度几乎一样。

最直观的变化是销售转化路径变了。以前给潜在客户演示产品,我要录一段操作视频,或者开腾讯会议手把手演示。现在直接让客户把我们的 MCP server 接进他们正在用的 AI 代理,让他们自己"指挥"代理去发现和报价。很多客户第一次看到代理自己完成这个流程时,反应都是"哇"——这比任何宣传文案都有说服力。

而且因为 MCP 的客户端侧特性,这套能力对本地模型场景同样有效。有用户跑的是本地部署的模型加 MCP 客户端,一样能用我们的工具,这无形中拓宽了产品触达的人群。

6.2 下一步扩展:Resources、Prompts 与远程部署

第一版只做了 Tools,后面我计划做三件事:

  • 加 Resources:把常用价格表、折扣规则做成 resource,让代理在上下文里直接看到,减少不必要的工具调用次数,降低 token 消耗。
  • 加 Prompts:预置"标准报价流程"的模板,代理按照模板一步步走,输出更稳定。
  • Streamable HTTP 远程部署:让远程 agent 也能接入,配上完整的认证和限流,这样产品就彻底变成一个"AI 可访问的服务"。

最后分享一个小技巧。每次改完工具描述,我都习惯让代理重新跑一遍"发现并报价"的完整流程,观察它在哪一步卡住,然后把卡住的原因写回描述里。迭代几轮之后,工具描述会越来越像一份"给新员工的完整操作手册",AI 的调用成功率也会稳定在一个很高的水平。MCP server 的代码本身不难,难的是把你的业务知识准确地翻译成机器能推理的文字。我在这上面花的时间,比写协议代码多得多,但回报也直接体现在代理的行为质量上。

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

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

立即咨询