前阵子我终于把“让 AI 代理能自动给我的小产品报价”这件事打通了。事情起因很简单:有用户直接在 AI 对话框里问我的小工具怎么收费、有哪些版本、能不能批量授权,结果 AI 一本正经地胡说八道,给出一个我从来没定过的价格。与其等大模型被喂错信息,不如主动把产品目录和报价逻辑做成一个 MCP server,让 AI agent 通过标准协议自动“发现”我的产品,再按真实规则报价。
如果你没接触过 MCP,可以把它理解成给 AI 插上的“USB 接口”——AI 不再只会聊天,而是通过一套标准化协议去调用外部工具。这套协议全称 Model Context Protocol,GPT、Claude 这类模型通过它读写外部数据、触发工具。这篇文章不聊概念,我会直接从一个独立开发者的视角,讲讲我从零开始给自己的小产品写 MCP server 的完整过程:需求怎么拆、工具怎么设计、代码怎么写、客户端怎么接,以及我踩过的几个坑。
1. 先拆需求:为什么小产品需要被 AI“发现”
1.1 从“用户问 AI”到“AI 主动问你”
以前小产品要被人知道,靠搜索引擎、应用商店、公众号。现在多了一个入口:用户让 Claude、ChatGPT、Cursor 里的 AI agent 帮忙找产品或报价。我试过让 AI 推荐一款适合做批量 PDF 合并的小工具,它真的会“编”出来一个——编名称、编价格、编购买链接。如果你的产品没有接入这些模型可感知的渠道,它对你的产品就是“看不见、摸不着、只能编”。
MCP server 解决的就是这个问题:当用户在一个支持 MCP 的客户端(Claude Desktop、Cursor、Trae 等)里提问时,客户端会向已注册的 MCP server 发送list_tools调用,server 返回“我能提供这些能力”,AI 再根据用户意图选择合适工具去执行,整个流程里“发现产品”和“获取报价”都变成了可验证的真实程序逻辑,而不是模型瞎猜。
1.2 为什么是 MCP,而不是我熟悉的 REST API
我一开始也犯过嘀咕:我的产品有 REST API,文档也写得挺清楚,为什么还要再包一层 MCP?
区别在于调用方和交互方式。REST API 是给程序员调的,调用方必须知道接口路径、参数、鉴权方式,还要自己写请求代码。MCP server 是给 AI agent 调的,它通过协议把工具清单、参数结构、返回语义暴露给模型,模型只需要“看懂描述,然后自动拼参数去调用”。换句话说,REST API 是“说明书”,MCP 是“把说明书也塞给 AI,并让它直接操作仪器”。
我这里给出一张不太严谨但很直观的对比表:
| 维度 | REST API | MCP Server |
|---|---|---|
| 调用方 | 开发者写的程序 | AI agent / 支持 MCP 的客户端 |
| 发现机制 | 人工读文档 | 协议层list_tools自动发现 |
| 参数传递 | 开发者按文档构造 | 模型根据描述自动生成 JSON 参数 |
| 返回格式 | 由接口自行定义 | 按 MCP 约定封装,带内容和结构化字段 |
| 最适合的场景 | 可控的应用程序交互 | 让 AI 自主理解并完成多步任务 |
对于“让 AI 代理自动发现并报价”这个目标,MCP 是成本最低的路径。我不需要维护两套文档,不需要让模型去理解一长串 Markdown 文档,只要把工具和数据暴露出去,客户端会自动完成发现和调用。
1.3 “报价”场景的特殊性:绝对不能靠模型心算
报价这件事太容易出错了。价格涉及组合促销、会员折扣、量价阶梯、地区运费。如果你在工具描述里写“根据产品 ID、数量、地区报价”,然后让大模型自己心算一个结果,那一定会翻车。
我的原则只有一个:模型只负责识别意图和传参,所有数值计算必须回到我自己的程序里执行。这在 MCP 设计上很自然——报价工具接收结构化参数,内部读规则表、算价格、返回结果。模型的“聪明才智”用在理解“用户说的是要报价,哪个产品,多少数量,哪个地区”,而结果的准确性和一致性,完全由我的代码保证。
2. 方案选型:SDK、传输方式与工具设计
2.1 SDK 选型:Python 用 fastmcp,真省事
MCP 官方提供了 TypeScript SDK 和 Python SDK。我作为独立开发者,后端主要用 Python,所以选了 Python 生态。用官方 SDK 其实不难,但需要自己处理协议消息、校验 JSON Schema、实现 lifecycle handler,样板代码量不小。后来我换成了社区非常流行的fastmcp库,体验好了非常多。
fastmcp核心价值是“装饰器写工具、Pydantic 定义参数、自动生成 MCP 协议字段”。我只需要定义一个FastMCP实例,用@mcp.tool()装饰几个函数,它自动完成工具注册、参数 schema 生成、请求分发。如果你不是特别需要深度定制协议层,我强烈建议直接用这个库,半小时就能跑通。
如果坚持官方 SDK,注意版本对齐,MCP 协议现在更新很快,不同版本的 SDK 与客户端之间的兼容性偶尔出问题。fastmcp会帮我锁相对稳定的协议版本,省掉很多兼容性痛苦。
2.2 传输方式:stdio 和 Streamable HTTP 怎么选?
MCP 目前主流有两种传输方式:stdio和Streamable HTTP(旧的 SSE 传输逐渐被替代)。
stdio模式下,MCP client 作为父进程启动 server,双方通过标准输入和标准输出通信。这种方式非常适合本地桌面客户端,一个 agent 对应一个 server 进程,配置简单。我在本地调试和连接 Claude Desktop、Trae 的时候用的就是 stdio。
Streamable HTTP 模式则是让 server 跑成一个 HTTP 服务,客户端通过 URL 来连接,适合远程部署、多客户端共享、服务化场景。比如我想让多个用户或线上 agent 都能调用我的报价服务,就把它发布为一个 HTTP endpoint。
我的建议是:
- 只给自己本地用:直接 stdio,几行 JSON 配置搞定;
- 要做成服务给别人/别的系统用:用 Streamable HTTP,加鉴权,考虑并发;
- 两种模式可以在同一个代码里都支持,
fastmcp提供了mcp.run(transport="stdio"/"streamable-http")的切换,一个参数的事。
2.3 工具设计:每个 MCP Tool 都是“一句话 + 一个 Schema”
MCP server 的核心资产是“工具”(Tools)。AI 代理能发现什么,完全取决于你暴露了哪些工具以及它们的描述写得好不好。我设计了两个工具:
search_products:搜索我的产品目录,按分类/关键词/状态过滤,返回产品 ID、名称、规格、是否在售。get_product_quote:根据产品 ID、数量、所在地区、会员状态计算报价,返回单价、总价、运费、优惠明细。
设计的原则是“语义单一、描述精准”。search_products只做目录检索,不做计算;get_product_quote只做报价,不返回目录。这样 AI 在执行“帮我找找能一键合并 PDF 的产品,并报 10 个授权的价格”这个任务时,会自然地先调用前一个工具拿到产品 ID,再调用后一个工具拿到报价,逻辑非常清晰。
工具描述里我还要额外写清楚“什么时候该调用这个工具”。比如get_product_quote的描述里我会加上“当用户要求获取价格、报价、折扣、总价时调用;仅在用户表达具体购买意向后调用”。这看起来啰嗦却极其重要——描述写得太宽泛,模型会在不该报价的时候也去报价;写得太窄,模型可能漏调用。
每个工具的入参我都用 Pydantic 模型定义,让fastmcp自动生成 JSON Schema。这里尤其要注意字段描述也要写清楚。模型读不到你的源码,它只能读到 schema 里的description。比如quantity字段,我写的是“需要授权的用户数量或采购数量,必须是大于 0 的整数,只允许 1~1000”,这对模型正确传参帮助巨大。
3. 实操过程:用 fastmcp 半小时搭起来
3.1 初始化项目与依赖
我建了一个独立目录,把它和我的主产品代码分开,只通过内部模块或静态数据文件访问产品信息。这样 MCP server 即便被模型调出问题,也不会影响主业务。
mkdir mcp-product-server cd mcp-product-server python -m venv .venr source .venr/bin/activate pip install fastmcp httpx注意 Mac/Linux 的source命令,Windows 是.venr\Scripts\activate,这里不同系统差异挺大,大家按自己的系统来。
项目结构:
mcp-product-server/ ├── products_data.json # 产品目录与报价规则 ├── server.py # MCP server 主文件 ├── pricing.py # 报价计算逻辑(纯函数) └── README.md我特意把pricing.py单独拆出来,原因是报价逻辑将来很可能复用给 REST API 或定时任务,纯函数化之后测试也容易。
3.2 产品目录数据准备
为了让 AI 代理“发现”产品,我先把产品目录整理成一份结构化 JSON。格式不需要太复杂,关键是字段语义明确,便于程序读取。我的products_data.json长这样:
[ { "sKu": "PDF-MERGE-PRO", "name": "PDF Merge Pro", "category": "pdf-tools", "tags": ["pdf", "merge", "batch"], "description": "支持批量合并、拆分 PDF 的桌面工具,Windows 和 Mac 可用。", "in_stock": true, "base_price": 129.0, "licenses": "single" }, { "sKu": "PDF-BATCH-10", "name": "PDF Batch Pack (10 seats)", "category": "pdf-tools", "tags": ["pdf", "batch", "team"], "description": "10 人团队版批量 PDF 授权。", "in_stock": true, "base_price": 899.0, "licenses": "team" } ]这个文件我放在 server 进程启动时加载到内存里。对于小产品来说,产品数量不会很多,内存加载完全够用,没必要引数据库。如果你的产品有成百上千个 SKU,再考虑用 SQLite 甚至外部 API。
3.3 实现“发现”工具
核心代码非常简单:
import json from typing import Optional from fastmcp import FastMCP mcp = FastMCP("my-product-server") with open("products_data.json", "r", encoding="utf-8") as f: PRODUCTS = json.load(f) @mcp.tool() def search_products( category: Optional[str] = None, keyword: Optional[str] = None, in_stock: bool = True, limit: int = 10, ) -> list[dict]: """搜索我的产品目录,返回产品列表。 当用户想了解有哪些产品、查找特定类型产品、询问是否在售时调用此工具。 - category: 产品分类,例如 pdf-tools、image-tools、video-tools - keyword: 模糊匹配产品名称、标签、描述中的关键词 - in_stock: 是否只返回在售产品 - limit: 返回结果数量上限,默认 10,最大 20 """ results = [] for p in PRODUCTS: if not in_stock or p["in_stock"]: if category and p["category"] != category: continue if keyword: haystack = " ".join([p["name"], " ".join(p["tags"]), p["description"]]) if keyword.lower() not in haystack.lower(): continue results.append(p) if len(results) >= limit: break return results这里有几个细节值得注意:
- 函数 docstring 实际上会变成工具描述,所以我把“何时调用”和参数含义都写在 docstring 里。
fastmcp会自动把 docstring 和参数类型解析为 MCP 的description字段。 - 返回的是
list[dict],fastmcp会把它序列化成 JSON。返回结构不宜太深,AI 处理扁平列表更稳。 - 我给每个搜索结果都带上
base_price字段,这样模型也能直接看到基础价格。但这不是报价,报价必须走get_product_quote。
3.4 实现“报价”工具
报价逻辑我放在pricing.py,然后用@mcp.tool()暴露:
from pydantic import BaseModel, Field class QuoteRequest(BaseModel): product_id: str = Field(..., description="产品 SKU ID,来自 search_products 返回的 sKu 字段") quantity: int = Field(..., ge=1, le=1000, description="购买数量/授权数量,大于0的整数") region: str = Field("CN", description="地区代码,可选 CN、US、EU、SG") is_member: bool = Field(False, description="是否为老用户/会员") @mcp.tool() def get_product_quote(request: QuoteRequest) -> dict: """根据产品 SKU、数量、地区和会员状态计算报价。 当用户要求获取具体价格、总价、折扣、运费时必须调用本工具。 返回结构化报价单,包含单价、数量、折扣、运费、总价和有效期。 """ from pricing import calculate_quote return calculate_quote(request.product_id, request.quantity, request.region, request.is_member)pricing.py里是纯计算逻辑:
def calculate_quote(product_id: str, quantity: int, region: str, is_member: bool) -> dict: # 从 PRODUCTS 里找到产品,这里简化 product = find_product_by_id(product_id) if not product: return {"error": "product not found", "sKu": product_id} unit_price = product["base_price"] # 量价阶梯 if quantity >= 50: unit_price = round(unit_price * 0.8, 2) elif quantity >= 20: unit_price = round(unit_price * 0.9, 2) elif quantity >= 5: unit_price = round(unit_price * 0.95, 2) # 会员折上折 if is_member: unit_price = round(unit_price * 0.95, 2) # 地区运费(简化规则) shipping = 0.0 if region == "CN": shipping = 0.0 elif region in ("US", "EU", "SG"): shipping = 29.0 if quantity < 10 else 0.0 subtotal = round(unit_price * quantity, 2) return { "sKu": product_id, "unit_price": unit_price, "quantity": quantity, "subtotal": subtotal, "shipping": shipping, "total": round(subtotal + shipping, 2), "currency": "CNY", "valid_until": "2026-12-31" }这里我要再次强调:让 AI“发现”产品没问题,但“报价”这个动作一定要全部拉到程序侧。我见过有人在工具描述里写“请根据 base_price 和数量自行计算总价”,结果模型把129 * 3算成369这种离谱结果。报价工具存在的意义就是让模型不需要做任何算术。
3.5 接入本地客户端
本地调试我用stdio模式。fastmcp在结尾加一行:
if __name__ == "__main__": mcp.run(transport="stdio")然后在 Claude Desktop 的配置文件中添加 server:
{ "mcpServers": { "my-product-server": { "command": "python", "args": ["/path/to/mcp-product-server/server.py"] } } }注意command必须是绝对路径下的 Python 解释器,尤其是在 Mac/Linux 下尽量用which python查一下,避免配置里写了/usr/bin/python而项目依赖装在 venv 里。我一开始在这里踩了坑,后面统一用.venr/bin/python的绝对路径就稳定了。
在支持 MCP 的 IDE 中(比如 Cursor 或 Trae 的 MCP 配置面板),同样加一条命令启动项即可。配好后,AI 客户端会自动完成“发现”流程:你在对话框里问“你们的 PDF 合并工具买 10 个授权多少钱”,模型会先调用search_products,再调用get_product_quote。整个过程它对用户是透明的,只输出一个合理的报价单。
3.6 用 MCP Inspector 快速验证
在没有客户端的情况下,可以直接用官方调试工具来验证 server 是否正常工作:
npx @modelcontextprotocol/inspector python /path/to/server.pyInspector 会打开一个本地 Web 页面,界面左侧能看到Tools列表,右侧能手动调用每个工具并查看 JSON 返回。我强烈建议在接入客户端之前,先在这里把每个工具的输入输出都过一遍,能省去不少对接阶段的排查时间。
4. 常见问题与排查技巧实录
4.1 stdio 模式连不上:多半是 stdout 被污染了
我最开始跑通时,在 server 里加了几行print调试日志,结果客户端一直报告连接失败。原因是 stdio 模式下,协议消息就是通过 stdout 传输的,你在 stdout 里打印任何额外内容,都会把协议流弄坏。任何日志都只能走stderr或外部文件。
正确做法是设置一个专门的文件日志,或者用logging模块输出到 stderr。在server.py开头加上:
import logging logging.basicConfig(stream=sys.stderr, level=logging.INFO)这样你看本地终端时能实时看到日志,又不会干扰 MCP 协议通信。
4.2 AI 传参错误:收紧 JSON Schema 的描述和约束
有一次测试时,模型把quantity传成了字符串"10个",就是因为我的 Pydantic 字段没有严格类型约束,schema 里type字段太宽松。后来我强制quantity: int,加上ge=1, le=1000,模型基本就稳定传整数了。
经验就是:能收窄的字段一定收窄。字段描述里要把单位、取值范围、默认值都写清楚。JSON Schema是模型理解你的接口的唯一途径,写得越细,模型出错率越低。
4.3 幻觉和乱报价:所有数值必须程序计算
我在测试阶段故意问 AI:“如果买 3 套,会员,运费到美国,总价多少?”如果get_product_quote没被触发,AI 就会按照脑海里已经抓取的base_price自己心算。只要我让get_product_quote工具名足够显眼、描述足够明确、入参足够结构化之后,模型就会稳定调用它。
我还做了另一个保险:在报价工具返回的数据里带一个valid_until字段和currency。这样 AI 拿到的是一份“看起来很正式的报价单”,它就没有动力自己重算一份了。
4.4 并发与超时:远程部署时考虑无状态化
如果你只是本地给自己用,一个 agent 对应一个 stdio 进程,并发问题基本不存在。但如果我把 MCP server 部署成 Streamable HTTP 服务,就要考虑多个 agent 同时请求怎么办。
我的建议是保持无状态。所有报价计算都不依赖进程内存中的可变状态(产品数据可以启动时加载或直接读文件),每次请求都是独立计算。还要在服务端设置一个合理的超时上限,比如计算超过 10 秒直接返回错误,避免一个慢请求拖住整个 worker。fastmcp在新版本里对 Streamable HTTP 的支持很成熟,用mcp.run(transport="streamable-http")即可。
4.5 日志管理:从 print 到结构化 JSON 日志
前面提到 stdio 不能用 stdout 打印日志,那线上服务怎么做日志管理?我推荐直接用标准logging,格式改为 JSON,便于后面接日志平台或做关键词搜索。
用 Python 的logging加一个简单的 JSON Formatter:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record): log_entry = { "time": self.formatTime(record, "%Y-%m-%d %H:%M:%S"), "level": record.levelname, "module": record.module, "message": record.getMessage(), } return json.dumps(log_entry, ensure_ascii=False)这样每条日志都是一行 JSON,排查时 grep 一下某个 SKU 就能找到对应报价链路的所有记录。建议至少记录:调用方请求 ID(MCP request id)、工具名、入参摘要、返回结果状态、耗时。这对后续分析模型调用行为特别有帮助。
5. 几个我踩过的坑与后续扩展思路
5.1 工具过度暴露:不是所有能力都该做成 Tool
一开始我为了让 AI “更懂产品”,把产品规格表、价格表、库存表全部做成了工具。结果模型经常不知道该调哪个,甚至一次调用里带出一大堆无用信息,反而干扰决策。
后来我只保留了“搜索产品”和“报价”两个核心工具,其余产品说明类内容换成 MCP 的resources暴露。Resources 是另一种数据暴露方式,它更像“可被检索的静态文档”,模型按需读取而非主动调用。这个思路很值得大家参考:能做成资源文档的,别做成工具;工具越少,模型判断越准。
5.2 对返回的结果做“语义包装”,减少 AI 二次发挥
get_product_quote返回的原始 JSON 是这样的:
{ "sKu": "PDF-MERGE-PRO", "unit_price": 122.55, "quantity": 10, "subtotal": 1225.5, "shipping": 0, "total": 1225.5, "currency": "CNY", "valid_until": "2026-12-31" }其实模型拿这个给用户看也够用,但我后来在返回前加了一步“整理成人类可读的报价单摘要”,把折扣信息和会员优惠也写进去。这样模型几乎不用自己组织语言,直接转发给用户,准确性更高、体验更自然。
5.3 后续可以这样扩展
写完之后,我发现自己这套思路完全可以复用:比如把单个产品的 MCP server 升级成支持多产品、多商户的报价网关;再比如把报价工具接入到真实的订单系统,让 AI 不只报参考价,还能直接锁定库存、生成订单草稿。另一个有意思的方向是在 MCP server 里接入支付能力,让 agent 完成“发现-报价-下单”全链路闭环。
我个人的体会是,给 AI 写 MCP server 的核心并不是“套一个协议框架”,而是想清楚你希望模型在哪些环节介入、哪些环节程序必须兜底。报价计算这种一错就坏信誉的事,一定让程序做;搜索和意图理解这种事,放心交给模型。只要这个边界立住了,MCP server 就会非常可靠。
最后再分享一个小经验:当我第一次把配置好的 MCP server 接进 IDE 时,故意用一句非常口语化的话去测试——“帮我看下你们家有没有能批量合并 PDF 的东西,给我报个 10 台的价格”。看着 AI 自动列出工具、传参、正确算出总价的那一瞬间,你会觉得这半小时的配置确实值了。