1. 先搞清楚 MCP 到底在解决什么问题
1.1 大模型接入的痛点:为什么需要 MCP
大模型本身是个“光杆司令”。你问它今天天气怎么样,它只能根据训练数据里的统计规律编一个答案,因为它没有实时获取外部信息的能力。你让它帮你查数据库里的订单,它也无能为力,因为它碰不到你的数据库。
过去两年,行业里解决这个问题的办法是“一个模型配一套工具”。比如你要让大模型调用天气 API,就得写一段专门的函数调用代码;要让它读本地文件,又得写另一套逻辑。每个模型厂商的函数调用格式还不一样,OpenAI 一套、Claude 一套、国内各家又一套。结果就是:每换一个模型,所有工具对接代码都得重写一遍。
MCP(Model Context Protocol)就是冲着这个痛点来的。它做的事情,本质上和 USB-C 接口统一充电标准是一个思路——把“模型怎么调用外部能力”这件事标准化。你按照 MCP 协议写一个 Server,任何支持 MCP 的客户端(Claude Desktop、Cursor、Cherry Studio、Dify 等)都能直接接上去用,不用改代码。
注意:MCP 不是模型本身的能力,它是一个通信协议。模型通过 MCP Client 与 MCP Server 通信,Server 再去调用真正的工具或数据源。这个链路要理清楚,后面排查问题才不会迷糊。
1.2 MCP 的核心架构:三个角色各干什么
MCP 的架构不复杂,就三个角色:
- MCP Host:你用的那个 AI 应用,比如 Claude Desktop、Cursor、Cherry Studio。它负责跟用户交互,也负责管理 MCP Client。
- MCP Client:Host 内部的一个组件,专门负责跟 MCP Server 建立连接、发送请求、接收响应。通常你不需要自己写 Client,Host 已经内置了。
- MCP Server:你真正要写的东西。它对外暴露能力(Tools、Resources、Prompts),对内连接你的数据库、API、文件系统等。
三者之间的关系可以用一个生活场景类比:Host 是餐厅前台,Client 是服务员,Server 是后厨。顾客(用户)跟前台点菜,服务员把单子传给后厨,后厨做好菜再通过服务员端回来。你作为开发者,主要工作就是“开后厨”。
1.3 MCP Server 能提供什么:Tools、Resources、Prompts
MCP Server 对外暴露三种能力,理解它们的区别很关键:
| 能力类型 | 作用 | 类比 | 典型场景 |
|---|---|---|---|
| Tools | 可被模型调用的函数 | 后厨的炒菜动作 | 查数据库、发邮件、调 API |
| Resources | 可被读取的数据 | 后厨的食材柜 | 读文件、读配置、读日志 |
| Prompts | 预定义的提示模板 | 后厨的菜谱 | 标准化问答、代码审查模板 |
Tools 是最常用的。模型会根据用户的问题,自动判断要不要调用某个 Tool,然后生成调用参数。Resources 更像是“模型主动去读”的数据,比如你让模型分析一个本地文件,它通过 Resource 读取文件内容。Prompts 用得相对少,主要是给用户提供快捷入口。
实操心得:新手最容易把 Tools 和 Resources 搞混。记住一个判断标准——如果这个能力需要模型“执行一个动作并拿到结果”,用 Tools;如果只是“读取一份数据”,用 Resources。当然,Tools 也能返回数据,所以实际项目中 Tools 用得更多。
1.4 哪些场景适合接 MCP
不是所有场景都值得上 MCP。根据我的经验,以下几类场景收益最明显:
- 企业内部工具链整合:公司内部有 Jira、Confluence、GitLab、数据库,想让大模型统一调用。写一个 MCP Server 把常用操作封装成 Tools,比给每个模型单独写函数调用代码省事得多。
- 本地开发辅助:让 AI 编程助手能读你的项目文件、查数据库 schema、执行测试命令。Cursor 和 Claude Desktop 都支持 MCP,接上之后体验提升明显。
- 多模型切换场景:今天用 Claude,明天用 GPT,后天用国产模型。如果工具层用 MCP 标准化了,切换模型时只需要换 Host,Server 不用动。
- 需要精细权限控制的场景:MCP Server 是你自己写的,哪些操作允许、哪些禁止,完全由你控制。比直接把 API Key 丢给模型安全得多。
反过来,如果你只是想让模型回答几个通用问题,或者你的工具调用逻辑非常简单(就一个 API),那直接写函数调用可能更快,没必要上 MCP。
2. 动手之前:环境准备与关键选型
2.1 开发语言与 SDK 选择
MCP 官方提供了多种语言的 SDK,目前生态最成熟的是 Python 和 TypeScript。选哪个?我的建议是:
- Python:适合数据类、AI 类场景。如果你要接数据库、做数据处理、调机器学习模型,选 Python。官方 SDK 是
mcp包,安装简单。 - TypeScript:适合 Web 类、前端工具类场景。如果你要接 HTTP API、做浏览器自动化、跟 Node 生态集成,选 TypeScript。官方 SDK 是
@modelcontextprotocol/sdk。
两个 SDK 的功能覆盖基本一致,选你顺手的就行。我个人的项目里,数据相关的用 Python,工具集成类的用 TypeScript。
# Python 环境准备 pip install mcp # TypeScript 环境准备 npm install @modelcontextprotocol/sdk注意:Python SDK 要求 Python 3.10 及以上版本。如果你用的是 3.9 或更低,先升级,否则会遇到类型注解相关的报错。
2.2 Host 端选择:你用什么来测试
写完 Server 总得有个 Host 来测。常见的选择:
- Claude Desktop:最省事的测试环境。配置文件里加一行 Server 路径就能用,适合快速验证。
- Cursor:如果你本来就用 Cursor 写代码,直接在设置里加 MCP Server,边写边测。
- Cherry Studio:国产开源客户端,对 MCP 支持不错,界面友好,适合不习惯命令行的用户。
- Dify:如果你在做 AI 应用编排,Dify 支持接入 MCP,可以把 Server 作为工作流的一个节点。
我一般用 Claude Desktop 做快速验证,用 Cursor 做开发调试。两个都配好,切换成本很低。
2.3 通信方式:stdio 还是 SSE
MCP 支持两种传输方式:
- stdio:Server 作为子进程启动,通过标准输入输出通信。适合本地工具,配置简单,不需要网络端口。
- SSE(Server-Sent Events):Server 作为 HTTP 服务运行,通过 SSE 推送消息。适合远程服务、多客户端共享的场景。
大部分本地工具场景用 stdio 就够了。只有当你需要把 Server 部署到服务器上、让多个客户端同时连接时,才需要 SSE。
// Claude Desktop 配置 stdio 方式的示例 { "mcpServers": { "my-server": { "command": "python", "args": ["/path/to/server.py"] } } }实操心得:stdio 方式下,Server 的日志千万不要往 stdout 打,否则会污染协议通信,导致 Host 解析失败。日志统一走 stderr,或者写到文件里。这个坑我踩过,排查了半天才发现是 print 语句惹的祸。
2.4 依赖管理与项目结构
一个规范的 MCP Server 项目结构大概长这样:
my-mcp-server/ ├── server.py # 入口文件 ├── tools/ # 各个 Tool 的实现 │ ├── __init__.py │ ├── database.py │ └── api.py ├── resources/ # Resource 实现 ├── config.py # 配置管理 ├── requirements.txt # 依赖清单 └── README.md用虚拟环境隔离依赖,别把全局环境搞乱。Python 用venv或conda,TypeScript 用项目本地的node_modules。
3. 从零写一个 MCP Server:完整实操
3.1 最小可运行 Server 的骨架
先写一个最简单的 Server,只提供一个 Tool,功能是“把两个数字相加”。目的是跑通链路,别一上来就搞复杂的。
from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent # 创建 Server 实例 app = Server("demo-server") # 注册 Tool 列表 @app.list_tools() async def list_tools(): return [ Tool( name="add_numbers", description="计算两个数字的和", inputSchema={ "type": "object", "properties": { "a": {"type": "number", "description": "第一个数字"}, "b": {"type": "number", "description": "第二个数字"} }, "required": ["a", "b"] } ) ] # 处理 Tool 调用 @app.call_tool() async def call_tool(name: str, arguments: dict): if name == "add_numbers": result = arguments["a"] + arguments["b"] return [TextContent(type="text", text=f"结果是 {result}")] raise ValueError(f"未知工具: {name}") # 启动 Server async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ == "__main__": import asyncio asyncio.run(main())这段代码虽然短,但包含了 MCP Server 的核心要素:声明能力(list_tools)、实现能力(call_tool)、启动服务(stdio_server)。跑通这个,后面就是往里加东西。
3.2 定义 Tool 的输入 Schema:别偷懒
inputSchema是模型判断“要不要调这个 Tool、怎么传参”的依据。写得好不好,直接决定模型调用的准确率。
几个原则:
- description 要写清楚:不要写“查询数据”,要写“根据用户 ID 查询订单列表,返回订单号、金额、状态”。模型靠这个描述来决定是否调用。
- 参数类型要明确:用 JSON Schema 标准类型,number、string、boolean、array、object。别用模糊的“any”。
- 必填项要标 required:不标的话模型可能不传,导致你的代码报 KeyError。
- 给参数加 description:尤其是那些名字不够直观的参数。
Tool( name="query_orders", description="根据用户ID查询该用户的订单列表,返回订单号、下单时间、金额和状态", inputSchema={ "type": "object", "properties": { "user_id": { "type": "string", "description": "用户唯一标识,通常是手机号或邮箱" }, "status": { "type": "string", "enum": ["pending", "paid", "shipped", "completed"], "description": "订单状态筛选,不传则返回全部" }, "limit": { "type": "integer", "description": "返回条数上限,默认20", "default": 20 } }, "required": ["user_id"] } )实操心得:description 里把返回字段也写清楚,模型在组织最终回答时会更准确。我试过不写返回字段,模型经常把“订单状态”说成“支付状态”,虽然意思差不多,但用户看着别扭。
3.3 实现 Tool 调用逻辑:错误处理是关键
call_tool函数里,除了正常逻辑,最重要的是错误处理。模型调用 Tool 时,参数可能不对、外部服务可能挂掉、数据可能不存在。这些情况都要有明确的返回,而不是直接抛异常。
@app.call_tool() async def call_tool(name: str, arguments: dict): if name == "query_orders": user_id = arguments.get("user_id") if not user_id: return [TextContent(type="text", text="错误:缺少 user_id 参数")] try: orders = await fetch_orders_from_db( user_id=user_id, status=arguments.get("status"), limit=arguments.get("limit", 20) ) if not orders: return [TextContent(type="text", text=f"用户 {user_id} 没有符合条件的订单")] # 格式化成模型容易理解的文本 lines = [f"共找到 {len(orders)} 条订单:"] for o in orders: lines.append(f"- 订单号 {o['id']},{o['time']},{o['amount']}元,状态:{o['status']}") return [TextContent(type="text", text="\n".join(lines))] except Exception as e: return [TextContent(type="text", text=f"查询失败:{str(e)}")] raise ValueError(f"未知工具: {name}")注意几个细节:返回给模型的内容要结构化、易读,别直接丢 JSON 字符串。模型对自然语言格式的解析更稳。错误信息也要返回给模型,让它知道发生了什么,而不是让 Host 端报错。
3.4 接入真实数据源:以数据库为例
把上面的fetch_orders_from_db换成真实实现。假设用 PostgreSQL:
import asyncpg async def fetch_orders_from_db(user_id: str, status: str = None, limit: int = 20): conn = await asyncpg.connect( host="localhost", database="shop", user="readonly_user", password="your_password" ) try: query = "SELECT id, created_at, amount, status FROM orders WHERE user_id = $1" params = [user_id] if status: query += " AND status = $2" params.append(status) query += " ORDER BY created_at DESC LIMIT $" + str(len(params) + 1) params.append(limit) rows = await conn.fetch(query, *params) return [dict(r) for r in rows] finally: await conn.close()注意:数据库连接一定要用只读账号。MCP Server 暴露给模型的能力,模型可能会“自由发挥”调用,万一给了写权限,后果不可控。这是安全底线,别图省事。
3.5 在 Claude Desktop 中配置并验证
Server 写好了,配置到 Claude Desktop 里。找到配置文件:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
加入你的 Server:
{ "mcpServers": { "order-query": { "command": "python", "args": ["/Users/yourname/projects/my-mcp-server/server.py"], "env": { "DB_PASSWORD": "your_password" } } } }重启 Claude Desktop,在对话框里输入“帮我查一下用户 138xxxx 的订单”,看它是否自动调用你的 Tool。如果没反应,检查 Claude 的日志(macOS 在~/Library/Logs/Claude/),看 Server 是否启动成功。
4. 进阶玩法:让 MCP Server 更实用
4.1 多 Tool 协作与工作流编排
一个 Server 里可以注册多个 Tool,模型会根据任务自动组合调用。比如你有一个“查订单”Tool 和一个“查物流”Tool,用户问“我的订单到哪了”,模型会先调查订单拿到订单号,再调查物流拿到轨迹。
但这里有个问题:模型不一定每次都按你期望的顺序调用。解决办法是在 Tool 的 description 里写清楚依赖关系,比如“查询物流前需要先通过 query_orders 获取订单号”。
更复杂的编排可以放在 Server 内部实现——对外只暴露一个 Tool,内部自己串联多个步骤。这样模型调用更简单,但灵活性降低。两种方式各有取舍,看场景选。
4.2 Resource 的使用:让模型读文件
Resource 适合“模型需要读取一份数据”的场景。比如让模型分析项目里的配置文件:
@app.list_resources() async def list_resources(): return [ Resource( uri="file:///project/config.yaml", name="项目配置", description="项目的 YAML 配置文件", mimeType="text/yaml" ) ] @app.read_resource() async def read_resource(uri: str): if uri == "file:///project/config.yaml": with open("/project/config.yaml", "r") as f: content = f.read() return content raise ValueError(f"未知资源: {uri}")Resource 的 URI 格式有讲究,用file://、db://、api://等前缀区分类型。模型看到 URI 就知道这是什么类型的数据。
4.3 流式输出与长任务处理
有些 Tool 执行时间很长(比如跑一个数据分析任务),如果等全部算完再返回,用户体验很差。MCP 支持流式返回,可以边算边推。
@app.call_tool() async def call_tool(name: str, arguments: dict): if name == "long_task": # 分步返回进度 yield [TextContent(type="text", text="开始处理...")] await asyncio.sleep(1) yield [TextContent(type="text", text="第一步完成...")] await asyncio.sleep(1) yield [TextContent(type="text", text="全部完成,结果如下:...")]实操心得:流式输出在 Cherry Studio 里体验最好,能实时看到进度。Claude Desktop 对流式的支持稍弱,有时候会等全部完成才显示。测试时两个客户端都试试,确保兼容。
4.4 权限控制与安全边界
MCP Server 的安全模型很简单:你暴露什么,模型就能用什么。所以安全控制全在 Server 端。
几个必须做的:
- 数据库只读账号:前面说过了,再强调一遍。
- 文件访问白名单:Resource 读取文件时,校验路径是否在允许目录内,防止路径穿越。
- API 调用限流:Tool 里调外部 API 时加限流,防止模型疯狂调用把配额用完。
- 敏感操作二次确认:如果 Tool 涉及写操作(发邮件、改数据),在 Server 端加确认逻辑,或者干脆不暴露给模型。
ALLOWED_DIRS = ["/project/data", "/project/docs"] def is_path_allowed(path: str) -> bool: real_path = os.path.realpath(path) return any(real_path.startswith(d) for d in ALLOWED_DIRS)5. 常见问题与排查技巧实录
5.1 Server 启动失败:从日志入手
现象:Claude Desktop 里看不到你的 Tool,或者提示 Server 连接失败。
排查步骤:
- 先手动跑 Server:
python server.py,看有没有报错。很多问题是依赖没装、路径不对。 - 检查配置文件路径是否正确,
args里的路径要用绝对路径。 - 看 Host 的日志。Claude Desktop 的日志在
~/Library/Logs/Claude/mcp.log,里面会记录 Server 的 stderr 输出。 - 确认 Python 版本和依赖版本。
mcp包版本更新较快,有时候新版本改了 API,旧代码跑不起来。
常见坑:macOS 上 Claude Desktop 启动 Server 时用的 Python 可能不是你终端里的那个。在配置文件里写完整的 Python 路径,比如
/usr/local/bin/python3,别只写python。
5.2 Tool 调用不触发:description 的问题
现象:用户问了相关的问题,但模型就是不调用你的 Tool。
原因:大概率是 Tool 的 description 写得不够清楚,模型没理解这个 Tool 能干什么。
解决:把 description 改得更具体。比如“查询订单”改成“根据用户ID查询该用户的订单列表,包括订单号、金额、状态”。另外,inputSchema里参数的 description 也要写清楚。
还有一个技巧:在 Server 的instructions里写一段总体说明,告诉模型这个 Server 是干什么的、什么时候该用。
app = Server( "order-server", instructions="这个 Server 用于查询电商订单信息。当用户询问订单、物流、退款相关问题时,优先调用本 Server 的 Tool。" )5.3 返回值格式问题:模型理解不了
现象:Tool 调用成功了,但模型对返回结果的解读不对。
原因:返回的文本格式太乱,或者包含模型不认识的编码。
解决:返回纯文本,结构清晰。用换行和短横线组织列表,别用复杂的嵌套 JSON。如果必须返回 JSON,确保是合法的、格式化的 JSON 字符串。
另外注意编码问题。中文内容确保用 UTF-8,别出现乱码。
5.4 超时与连接断开:长任务的坑
现象:Tool 执行时间超过 30 秒,Host 报超时。
原因:MCP 默认有超时限制,不同 Host 的超时时间不一样。Claude Desktop 大概 60 秒,Cursor 可能更短。
解决:长任务用流式返回,先返回“处理中”,再分步返回结果。或者把长任务拆成多个短 Tool,让模型分步调用。
如果任务确实需要很长时间,考虑改成异步模式:Tool 立即返回一个任务 ID,然后提供另一个 Tool 来查询任务状态。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Server 启动失败 | 依赖缺失/路径错误 | 手动运行看报错 | 补依赖、用绝对路径 |
| Tool 不触发 | description 不清 | 检查 Tool 描述 | 写具体描述和参数说明 |
| 返回值乱码 | 编码问题 | 检查输出编码 | 统一 UTF-8 |
| 调用超时 | 任务太长 | 看 Host 日志 | 流式返回或拆分任务 |
| 参数缺失 | required 没标 | 检查 inputSchema | 补 required 字段 |
| 连接断开 | stdout 被污染 | 检查 print 语句 | 日志走 stderr |
最后分享一个排查技巧:在 Server 里加一个“echo”Tool,功能就是原样返回输入参数。当你不确定是模型没调用、还是调用参数不对、还是返回值有问题时,先用 echo 测一遍,能快速定位问题在哪一环。
5.6 性能优化:让 Server 响应更快
Server 响应慢,用户体验就差。几个优化点:
- 连接池:数据库连接用连接池,别每次调用都新建连接。
asyncpg的create_pool很好用。 - 缓存:频繁读取且变化不频繁的数据,加内存缓存。比如配置信息、字典表。
- 异步并发:多个独立的外部调用用
asyncio.gather并发执行,别串行等。 - 懒加载:Server 启动时别加载所有数据,用到再加载。
# 连接池示例 pool = await asyncpg.create_pool( host="localhost", database="shop", user="readonly_user", password="your_password", min_size=2, max_size=10 ) async def fetch_orders(user_id: str): async with pool.acquire() as conn: return await conn.fetch("SELECT * FROM orders WHERE user_id = $1", user_id)这些优化做完,Server 的响应时间能从秒级降到毫秒级,模型调用体验会好很多。
5.7 调试技巧:用 MCP Inspector 可视化排查
官方提供了一个调试工具 MCP Inspector,可以可视化地查看 Server 暴露了哪些 Tool、Resource,还能手动调用测试。
npx @modelcontextprotocol/inspector python server.py启动后会打开一个网页,左边是 Server 的能力列表,右边是调用面板。输入参数点调用,就能看到返回结果。排查问题时比看日志直观多了。
实操心得:每次改完 Server 代码,先用 Inspector 测一遍,确认 Tool 能正常调用、返回值格式正确,再去 Host 里测。这样能把“Server 本身的问题”和“Host 集成的问题”分开,排查效率高很多。
6. 从能用到好用:几个实战经验
6.1 Tool 粒度怎么定
Tool 不是越多越好,也不是越少越好。粒度太细,模型要调用很多次才能完成一个任务,容易出错;粒度太粗,模型不好理解,参数也复杂。
我的经验是:一个 Tool 对应一个完整的业务动作。比如“查订单”是一个 Tool,“查物流”是一个 Tool,但“查订单并计算总金额”就不该是一个 Tool——计算逻辑应该放在模型侧,或者单独做一个计算 Tool。
另外,Tool 数量控制在 10 个以内。超过 10 个,模型的调用准确率会下降,因为它要在更多选项里做选择。如果确实需要很多能力,拆成多个 Server。
6.2 错误信息怎么写才有用
Tool 返回错误时,别只写“失败”两个字。要告诉模型:什么失败了、为什么失败、可以怎么补救。
比如:
- 差:“查询失败”
- 好:“查询失败:数据库连接超时。请稍后重试,如果持续失败请联系管理员。”
模型拿到第二种错误信息,可以给用户更准确的回复,甚至自动重试。
6.3 版本管理与向后兼容
MCP Server 一旦被 Host 引用,改接口就要小心。Tool 的名字、参数、返回值格式,尽量保持向后兼容。
如果必须改,用版本号区分。比如query_orders_v2,旧版保留一段时间,让用户有时间迁移。
6.4 日志与监控
生产环境的 MCP Server 一定要有日志。记录每次 Tool 调用的:谁调的、什么参数、耗时多少、成功还是失败。这些数据能帮你发现性能瓶颈、定位问题、了解用户实际怎么用。
日志写到文件,别写 stdout。用 Python 的logging模块,配置好格式和轮转。
import logging logging.basicConfig( filename="/var/log/mcp-server.log", level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__)6.5 测试策略:单元测试加集成测试
Tool 的逻辑用单元测试覆盖,Mock 掉外部依赖。Server 的整体行为用集成测试,起一个真实的 Server 实例,用 MCP Client 调用。
# 单元测试示例 import pytest from unittest.mock import AsyncMock @pytest.mark.asyncio async def test_query_orders_success(): mock_fetch = AsyncMock(return_value=[ {"id": "123", "amount": 100, "status": "paid"} ]) result = await call_tool("query_orders", {"user_id": "u1"}) assert "123" in result[0].text集成测试可以用官方的mcp测试工具,或者自己写一个简单的 Client 来调。
6.6 部署与分发
本地用的 Server,直接放项目目录就行。如果要分发给团队用,考虑打包成可执行文件(PyInstaller)或者发布到包管理平台。
如果是远程 Server,用 SSE 方式部署,加好认证和限流。别裸奔,否则被人扫到就是灾难。
我个人在实际操作中的体会是:MCP 的价值不在于技术多复杂,而在于它把“模型接工具”这件事标准化了。以前每接一个新模型就要重写一遍工具代码,现在写一次 Server,到处都能用。这个省下来的时间,够你多做好几个功能了。刚开始写 Server 可能会觉得有点绕,但跑通一个之后,后面的就是复制粘贴改改逻辑,边际成本很低。