1. 从一次接口对接的崩溃说起:MCP 到底想解决什么问题
如果你最近半年在折腾 LLM 应用,大概率经历过这种场景:为了让模型能读到一个本地文件、查一次数据库、调一次内部接口,你得给每个模型客户端单独写一套适配代码。Claude Desktop 一套、Cursor 一套、自己写的 Agent 框架再来一套。每换一个宿主环境,之前写的工具调用逻辑就得推倒重来。这种重复劳动不是能力问题,是协议缺失带来的结构性浪费。
MCP,全称 Model Context Protocol,就是冲着这个痛点来的。它做的事情说白了很朴素:把“模型怎么拿到外部上下文”这件事,从各家自定义的私有约定,抽象成一套统一的、客户端与服务器分离的通信协议。你可以把它理解成 LLM 应用领域的 USB-C——模型是电脑,外部工具和数据源是各种外设,中间那根线就是 MCP。只要外设按 MCP 规范实现一次,任何支持 MCP 的宿主都能即插即用。
我最初接触 MCP 是在一个内部知识库检索项目里。当时团队为了让模型能查公司文档,写了一个基于 HTTP 的检索服务,然后在三个不同的客户端里各写了一遍调用封装。后来接入 MCP 之后,检索服务只保留一个 MCP Server 实现,三个客户端全部改成走协议,代码量直接砍掉三分之二。这个体验让我意识到,MCP 的价值不在于它多高深,而在于它把一件本该标准化的事情标准化了。
这篇文章适合几类人看:正在做 LLM 应用集成、被多客户端适配折磨的工程师;想给自己产品加“模型可调用”能力的工具开发者;以及单纯想搞明白 MCP 是什么、值不值得投入时间学习的技术决策者。我会从协议设计思路讲到实操落地,包括 Server 怎么写、Client 怎么接、踩过哪些坑,尽量把我知道的都倒出来。
2. MCP 协议的整体设计与核心思路拆解
2.1 为什么是“协议”而不是“框架”
很多人第一次听到 MCP 会下意识觉得“又是一个 Agent 框架”。这是个误解。框架解决的是“怎么编排逻辑”,协议解决的是“怎么通信”。MCP 本身不关心你的 Agent 怎么规划任务、怎么管理记忆,它只规定了一件事:一个 MCP Client 和一个 MCP Server 之间,用什么格式交换信息。
这个定位非常关键。因为框架是排他的——你用了 LangChain 就很难同时用别的编排方式;但协议是包容的——你的 MCP Server 可以被任何实现了 MCP Client 的宿主调用,不管那个宿主底层用的是哪套框架。这种“协议层解耦”带来的好处,在生态逐渐丰富之后会越来越明显。
从架构上看,MCP 采用的是经典的 Client-Server 模型,但有一个容易被忽略的细节:它支持多种传输方式。早期主要是标准输入输出(stdio),适合本地进程间通信;后来加入了基于 HTTP 的流式传输,适合远程服务。这个设计选择背后的逻辑是——本地工具和远程服务的使用场景差异很大,本地工具追求低延迟和简单部署,远程服务追求可扩展和多用户共享,用一套传输方式硬套两边都不舒服。
2.2 三个核心原语:Resources、Tools、Prompts
MCP 把 Server 能提供的能力抽象成三种原语,这个划分是整个协议的灵魂,理解了它基本就理解了 MCP 的设计哲学。
Resources(资源)是“可读取的数据”。比如一个文件的内容、一条数据库记录、一个 API 的返回结果。它的特点是只读、由 Client 主动请求、以 URI 标识。你可以把 Resources 理解成“模型可以看的资料”。
Tools(工具)是“可执行的动作”。比如发送一封邮件、创建一个日历事件、执行一次搜索。它的特点是有副作用、由模型决定是否调用、需要参数校验。Tools 是“模型可以做的事”。
Prompts(提示模板)是“预设的交互模板”。比如“帮我总结这段代码”这种常用指令,可以预先定义好,让用户一键调用。它的特点是可复用、由用户主动触发。
这三者的划分不是拍脑袋定的,而是对应了 LLM 交互中三种本质不同的需求:读数据、做动作、用模板。我见过一些实现把这三者混在一起,结果就是 Client 端很难做权限控制和用户确认——因为分不清哪些操作是安全的读取,哪些是有副作用的执行。按原语分开之后,权限粒度自然就清晰了。
2.3 能力协商机制:握手阶段发生了什么
MCP 连接建立时会有一个初始化握手,双方交换各自支持的能力集。Client 告诉 Server“我支持采样、支持根目录通知”,Server 告诉 Client“我提供工具、提供资源、提供提示模板”。这个机制看起来不起眼,但它是协议向前兼容的关键。
举个实际例子:早期版本的 MCP 没有采样(Sampling)能力,后来加进来了。如果 Server 不管 Client 支不支持就发采样请求,老 Client 会直接报错。有了能力协商,Server 可以先检查 Client 是否声明了采样能力,没有就走降级逻辑。这种设计让协议可以持续演进而不破坏已有实现,是很成熟的工程思路。
2.4 和传统 Function Calling 的本质区别
很多人会问:这不就是 Function Calling 吗?区别在哪?
Function Calling 是模型层面的能力——你给模型一堆函数定义,模型决定调哪个、传什么参数。它解决的是“模型怎么表达调用意图”。但 Function Calling 不解决“这些函数从哪来、怎么发现、怎么复用”。
MCP 解决的恰恰是后半段。它规定了工具怎么被描述、怎么被动态发现、怎么跨进程调用。你可以把 Function Calling 看成“点菜”,MCP 看成“菜单怎么来的、厨房怎么接单”。两者是互补关系,不是替代关系。实际项目里,MCP Server 提供的 Tools 最终往往就是通过 Function Calling 机制暴露给模型的。
3. 核心细节解析与实操要点
3.1 消息格式:JSON-RPC 2.0 的选择理由
MCP 底层用的是 JSON-RPC 2.0。这个选择我觉得挺务实。JSON-RPC 足够简单,请求、响应、通知三种消息类型覆盖了所有交互场景;同时它又是成熟的、有大量现成库的,不用自己造轮子。
一个典型的工具调用请求长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "search_docs", "arguments": { "query": "MCP 协议设计", "limit": 10 } } }响应则包含结果或错误:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "找到 3 篇相关文档..." } ] } }这里有个细节值得注意:content是一个数组,而不是单个字符串。这个设计是为了支持多模态返回——同一个工具调用可以同时返回文本、图片、资源引用等多种内容。我在做文档检索工具时就利用了这个特性,既返回摘要文本,又返回原文的资源链接,Client 端可以灵活选择怎么展示。
3.2 工具描述:Schema 怎么写才不容易翻车
Tools 的核心是输入参数的 JSON Schema 描述。这部分写得好不好,直接决定模型能不能正确调用你的工具。我踩过的坑基本都集中在这里。
第一个坑是描述太模糊。比如一个参数叫type,描述写“类型”,模型根本不知道填什么。正确做法是把枚举值列清楚,描述里说明每个值的含义。模型不是人,它没法靠常识补全你没写的信息。
第二个坑是参数过多。我见过一个工具定义了十几个参数,结果模型调用时经常漏填或填错。经验法则是:单个工具的参数控制在 5 个以内,超过就考虑拆成多个工具,或者把一组相关参数打包成一个对象。
第三个坑是缺少必填标记。JSON Schema 里的required字段一定要认真填。不填的话模型会以为所有参数都可选,然后给你返回一堆缺参数的调用。
一个写得比较规范的参数定义大概是这样:
{ "name": "query_database", "description": "查询内部知识库,返回匹配的文档片段。适用于需要查找公司内部资料的场景。", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "自然语言查询语句,建议使用完整问句而非关键词" }, "top_k": { "type": "integer", "description": "返回结果数量,默认 5,最大 20", "default": 5, "minimum": 1, "maximum": 20 } }, "required": ["query"] } }注意description里我特意写了“建议使用完整问句而非关键词”,这种引导性描述能显著提升调用质量。模型很吃这一套。
3.3 传输层:stdio 和 HTTP 怎么选
前面提到 MCP 支持多种传输方式,实际选型时怎么判断?
stdio适合本地工具。Server 作为子进程被 Client 启动,通过标准输入输出通信。优点是零网络配置、延迟极低、天然隔离;缺点是只能本地用、一个 Server 实例只能服务一个 Client。像文件系统访问、本地数据库查询、IDE 集成这类场景,stdio 是首选。
HTTP 流式传输适合远程服务。Server 独立部署,多个 Client 通过网络连接。优点是支持多用户、可水平扩展、便于集中管理;缺点是要处理网络问题、认证授权、连接管理。像 SaaS 工具集成、团队共享的知识库服务,就该用 HTTP。
我个人的判断标准很简单:如果这个工具需要访问用户本机的资源,用 stdio;如果这个工具是团队共享的服务,用 HTTP。中间地带的情况很少。
3.4 错误处理:别让一个工具挂掉整个会话
MCP 的错误处理有个容易忽略的点:工具执行失败不应该导致整个连接断开。协议区分了“协议层错误”和“工具层错误”。协议层错误(比如方法不存在)会导致请求失败;工具层错误(比如查询超时)应该作为正常响应返回,只是在content里标记isError: true。
这个区分很重要。我早期实现时把工具异常直接抛出去,结果一个数据库连接超时就把整个会话搞崩了,用户体验极差。正确做法是在工具内部捕获异常,转成带错误标记的正常响应返回。这样模型能看到错误信息,可以决定重试还是换个方式,会话本身不受影响。
4. 实操过程与核心环节实现
4.1 环境准备与依赖选择
写一个 MCP Server,语言选择上目前生态最成熟的是 TypeScript 和 Python。TypeScript 有官方 SDK,类型定义完善;Python 的 SDK 也很活跃,适合做数据处理类工具。我两个都用过,简单工具用 TypeScript 更省心,涉及机器学习或数据分析的用 Python 更顺手。
以 TypeScript 为例,初始化项目:
mkdir my-mcp-server cd my-mcp-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node这里zod是用来定义参数 Schema 的,比手写 JSON Schema 舒服很多,SDK 会自动把它转成协议需要的格式。
4.2 一个最小可用的 Server 实现
先看一个最简版本,提供一个查询天气的工具:
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; const server = new Server( { name: "weather-server", version: "1.0.0" }, { capabilities: { tools: {} } } ); const WeatherArgsSchema = z.object({ city: z.string().describe("城市名称,例如:北京"), unit: z.enum(["celsius", "fahrenheit"]).default("celsius") .describe("温度单位"), }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "get_weather", description: "查询指定城市的当前天气", inputSchema: { type: "object", properties: { city: { type: "string", description: "城市名称" }, unit: { type: "string", enum: ["celsius", "fahrenheit"] } }, required: ["city"] } } ] })); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== "get_weather") { throw new Error(`未知工具: ${request.params.name}`); } const args = WeatherArgsSchema.parse(request.params.arguments); // 实际项目中这里调用真实天气 API const temp = args.unit === "celsius" ? 22 : 72; return { content: [ { type: "text", text: `${args.city}当前温度 ${temp}°${args.unit === "celsius" ? "C" : "F"}` } ] }; }); const transport = new StdioServerTransport(); await server.connect(transport);这段代码虽然短,但包含了 MCP Server 的所有核心要素:能力声明、工具列表处理、工具调用处理、传输层连接。跑起来之后,任何支持 MCP 的 Client 都能发现并调用get_weather。
4.3 接入真实数据源:以知识库检索为例
光有玩具例子不够,说一个我实际做过的场景——把内部知识库包装成 MCP Server。
核心逻辑是:接收查询语句,调用向量检索,返回匹配的文档片段。关键点在于返回格式的设计。我最初只返回纯文本,后来发现模型经常需要引用来源,就改成了结构化返回:
return { content: [ { type: "text", text: `找到 ${results.length} 条相关记录:\n\n` + results.map((r, i) => `[${i + 1}] ${r.title}\n来源: ${r.source}\n内容: ${r.snippet}` ).join("\n\n") }, { type: "resource", resource: { uri: `kb://search/${encodeURIComponent(query)}`, mimeType: "application/json", text: JSON.stringify(results) } } ] };同时返回人类可读的文本和机器可解析的资源引用,模型可以按需使用。这个模式在需要精确引用的场景下特别有用。
4.4 Client 端接入:以配置 Claude Desktop 为例
Server 写好了,得让 Client 能连上。以 Claude Desktop 为例,配置文件在~/Library/Application Support/Claude/claude_desktop_config.json(macOS)或%APPDATA%\Claude\claude_desktop_config.json(Windows)。
配置内容:
{ "mcpServers": { "weather": { "command": "node", "args": ["/absolute/path/to/my-mcp-server/dist/index.js"], "env": { "API_KEY": "your-key-here" } } } }几个实操要点:路径必须用绝对路径,相对路径会找不到;env里可以传环境变量,敏感信息不要硬编码在代码里;改完配置要完全重启 Client,不是刷新页面那种重启。
重启之后,在对话框里应该能看到工具图标,说明 Server 连接成功。如果没看到,先检查 Server 进程能不能独立跑起来,再检查路径和权限。
4.5 调试技巧:日志往哪打
stdio 传输有个坑:Server 的标准输出被协议占用了,你console.log的内容会污染协议消息,导致解析失败。正确做法是把日志打到标准错误:
console.error("[DEBUG] 收到查询:", query);Client 端一般会把 stderr 收集起来展示,方便排查。我一开始不知道这个,console.log打了一堆调试信息,结果 Client 直接报协议解析错误,查了半天才发现是日志惹的祸。
如果用的是 HTTP 传输就没这个问题,正常打日志即可。但 stdio 场景下这个坑几乎人人都会踩一次。
5. 常见问题与排查技巧实录
5.1 连接类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Client 看不到工具 | Server 启动失败 | 手动执行启动命令,看报错 |
| 连接后立即断开 | 协议消息被污染 | 检查是否有 stdout 输出 |
| 工具列表为空 | 能力声明缺失 | 确认 capabilities 里有 tools |
| 路径找不到 | 用了相对路径 | 改成绝对路径 |
| 权限拒绝 | 文件无执行权限 | chmod +x 或检查用户权限 |
这张表基本覆盖了我遇到过的八成连接问题。其中“协议消息被污染”是最隐蔽的,因为 Server 本身不报错,只是 Client 那边解析失败,容易误以为是 Client 的问题。
5.2 工具调用失败的典型模式
参数校验失败:模型传的参数不符合 Schema。这种情况先别怪模型,回头看看你的 Schema 描述是不是有歧义。我遇到过一次,参数描述写“时间戳”,模型传了 ISO 格式字符串,但我 Schema 定义的是 integer。改成明确写“Unix 时间戳(秒)”之后就正常了。
超时:工具执行时间过长。MCP 本身没有强制超时,但 Client 通常有。我的做法是在工具内部设置超时,比如数据库查询超过 10 秒就主动返回错误,而不是让 Client 等。这样错误信息更可控。
返回内容过大:一次性返回几 MB 的文本,导致传输卡顿甚至失败。解决办法是做分页或截断,在描述里告诉模型“返回前 N 条,如需更多请调整参数”。我一般把单次返回控制在 100KB 以内。
5.3 几个我踩过的坑
坑一:在工具处理函数里做耗时初始化。我一开始把数据库连接放在每次工具调用时建立,结果每次调用都要等连接建立。正确做法是在 Server 启动时初始化连接,工具处理函数里直接复用。
坑二:忽略并发。stdio 场景下请求是串行的,问题不大;但 HTTP 场景下多个 Client 可能同时调用同一个工具,如果工具有共享状态就会出问题。我的经验是工具处理函数尽量写成无状态的,有状态的部分用锁或队列保护。
坑三:Schema 里用了模型不认识的类型。JSON Schema 支持很多类型,但不是所有模型都能正确处理。我实测下来,string、number、integer、boolean、array、object这几种最稳,null和联合类型偶尔会出问题。能用简单类型就别用复杂的。
坑四:工具命名太随意。do_stuff、handle、process这种名字模型根本猜不出用途。命名要具体,search_internal_docs比search好,create_calendar_event比create好。名字本身就是给模型的提示。
5.4 性能优化的几个实操点
工具调用的延迟主要来自三块:网络往返、工具执行、结果序列化。网络往返在 stdio 场景下可以忽略,HTTP 场景下要尽量复用连接。工具执行是大头,该加缓存加缓存,该异步异步。结果序列化容易被忽略,返回大对象时 JSON 序列化本身就要几百毫秒,能精简就精简。
我做过一个对比测试:同一个检索工具,返回完整文档和只返回摘要,端到端延迟差了将近一倍。后来改成默认返回摘要,需要全文时再单独请求,体验好很多。
6. 生态现状与扩展方向
6.1 当前生态里都有哪些 Server
MCP 生态这一年多发展得挺快,常见的 Server 类型基本都有人做了。文件系统访问、Git 操作、数据库查询、浏览器自动化、设计工具集成,这些高频场景都有现成实现。像 Playwright MCP 可以做浏览器自动化,Figma MCP 可以读取设计稿信息,这些在各自领域都挺实用。
我的建议是:动手写之前先搜一下有没有现成的。很多通用需求已经有成熟实现,直接用比自己写省事。只有当你的需求涉及内部系统、私有数据、特殊业务逻辑时,才需要自己开发。
6.2 自己开发 Server 的决策标准
什么情况下值得自己写一个 MCP Server?我的判断标准有三条:
第一,这个能力需要被多个 Client 复用。如果只有一个 Client 用,直接写死在里面更简单。
第二,这个能力涉及私有数据或内部系统。公开的 Server 访问不了你的内部资源,只能自己写。
第三,这个能力有稳定的接口边界。如果需求天天变,封装成 Server 反而增加维护成本。
三条都满足,那就值得写。只满足一两条,可以先观望或者用临时方案顶着。
6.3 安全考量:别把危险操作直接暴露
MCP 让模型能调用工具,这本身就带来安全风险。我的原则是:有副作用的操作必须加确认机制。删除文件、发送消息、修改数据这类操作,不能让模型直接执行,要经过用户确认。
实现上,可以在工具描述里标注风险等级,Client 端根据等级决定是否弹确认框。也可以在 Server 端做二次校验,比如删除操作要求传入一个确认令牌。具体方案看场景,但核心思路是——模型可以提议,人来做最终决定。
另外,工具的参数校验一定要严格。我见过一个 Server 直接把用户输入拼进 SQL 查询,这是典型的安全漏洞。参数校验、输入转义、权限检查,这些基本功不能省。
6.4 后续可以怎么扩展
如果你已经跑通了一个基础 Server,想继续深入,几个方向可以考虑:
一是多 Server 协同。一个 Client 可以同时连接多个 Server,让模型在多个工具集之间自由选择。这时候工具命名要避免冲突,最好加前缀区分。
二是动态工具注册。有些场景下工具列表不是固定的,需要根据用户权限或上下文动态变化。MCP 支持在运行时更新工具列表,Client 会收到通知。
三是资源订阅。Resources 支持订阅机制,当资源内容变化时 Server 主动通知 Client。这个特性适合做实时数据展示,比如监控面板。
四是采样能力。Server 可以反过来请求 Client 的模型做推理,实现 Server 内部的智能决策。这个能力比较新,用好了能做出很有意思的东西。
我在实际项目里的体会是,MCP 最大的价值不是技术本身多先进,而是它让“模型接入外部能力”这件事有了统一标准。标准建立起来之后,工具可以复用、经验可以积累、生态可以生长。对于做 LLM 应用的人来说,早点把 MCP 摸熟,后面会省很多重复劳动。最后分享一个小技巧:调试 Server 时,先用一个最简单的 echo 工具跑通全链路,确认连接、发现、调用、返回都正常,再往里加复杂逻辑。这样出问题时排查范围小,定位快。