1. 从“黑盒”到“白盒”:为什么我们需要拆解MCP协议
如果你最近在折腾AI编程助手,比如Cursor、Claude Code,或者关注一些前沿的AI开发工具,那么“MCP”这个词大概率已经在你眼前晃过好几次了。你可能已经知道,MCP(Model Context Protocol)能让你的AI助手连接上数据库、搜索引擎、文件系统,甚至是你自己写的工具,瞬间扩展它的能力边界。但当你兴致勃勃地想把一个MCP服务器(比如一个搜索工具或者一个文件操作工具)接入到你的AI工作流时,是不是常常卡在配置这一步?看着文档里简单的“通过STDIO或HTTP连接”,却对背后到底发生了什么一头雾水,一旦报错,调试起来就像在摸黑走路。
这就是典型的“黑盒”体验。我们只知道输入和输出,却对中间的数据流转、通信规则一无所知。今天这篇,我们就来亲手拆开MCP这个“黑盒”。我们不满足于仅仅知道“怎么配”,更要彻底搞懂“为什么这么配”。协议,就是设备或程序之间对话的“语法”和“规则”。拆解MCP协议,意味着我们将深入其通信层,理解每一个JSON-RPC消息的结构、STDIO和HTTP这两种传输方式的具体实现细节,以及数据是如何被封装成“资源”和“工具”供模型调用的。这对于开发者而言至关重要:它能让你在配置时胸有成竹,在调试时精准定位,甚至在需要时,可以自己动手定制或开发一个MCP服务器,真正把AI能力无缝嵌入到你自己的工作流中。
2. MCP协议的基石:深入理解JSON-RPC 2.0
在拆解MCP的传输层之前,我们必须先夯实它的语言基础。MCP协议的核心通信规范建立在JSON-RPC 2.0之上。你可以把它想象成MCP服务器(Server)和客户端(Client,通常是AI助手环境如Cursor)之间约定好的一种“书信格式”。双方都用这种格式写信、读信,才能确保沟通无误。
JSON-RPC 2.0是一个轻量级的远程过程调用(RPC)协议。它之所以被广泛采用,包括被MCP选为核心,是因为它极其简单、独立于传输方式(这意味着它可以通过STDIO、HTTP、WebSocket等多种方式传输),并且是基于JSON这种几乎无处不在的数据格式。
一个完整的JSON-RPC 2.0消息(无论是请求还是响应)都是一个JSON对象。让我们通过一个MCP中可能出现的具体例子来拆解它的结构:
一个“调用工具”的请求(Request):
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "search_web", "arguments": { "query": "MCP protocol latest developments" } } }jsonrpc: 固定字符串“2.0”,声明协议版本。这是必须字段,用于区分旧版本。id: 请求的唯一标识符(可以是字符串、数字或null)。客户端生成此ID,服务器必须在对应的响应中原样返回这个ID。这是实现请求-响应匹配的关键。例如,客户端可以同时发送ID为1的搜索请求和ID为2的读文件请求,即使响应返回的顺序是2在前1在后,客户端也能通过ID正确地将响应分发给对应的处理逻辑。method: 要调用的方法名称。在MCP中,这定义了一系列标准方法,如initialize,tools/list,tools/call,resources/list,resources/read等。tools/call就表示客户端请求服务器执行某个工具。params: 调用方法时传递的参数,是一个对象或数组。这里是一个对象,包含了工具名name和调用参数arguments。
服务器处理后的响应(Response):
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "Here are the latest developments on MCP..." } ] } }id: 必须与请求中的id完全一致,这里是1。result: 如果调用成功,这个字段包含方法返回的结果。在MCP的tools/call响应中,result通常包含一个content数组,里面是模型可以理解的文本或多媒体内容。
如果调用出错,响应会是(Error Response):
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found", "data": "The method 'tools/run' does not exist." } }error: 替代result字段。包含code(预定义错误码,如-32601表示方法不存在)、message(人类可读的错误信息)和可选的data(额外的错误详情)。
为什么JSON-RPC 2.0适合MCP?
- 无状态性:每个请求都是独立的,服务器不需要维护复杂的会话状态,这简化了服务器设计和提高了可扩展性。
- 双向通信:虽然经典模式是请求-响应,但JSON-RPC 2.0也支持通知(Notification),即没有
id的请求,服务器无需回复。MCP可以利用这一点进行服务器主动推送(尽管在初始版本中较少见)。 - 强类型的错误处理:标准化的错误码和结构,使得客户端能系统化地处理不同类别的错误(如解析错误、无效参数、内部错误等)。
注意:在MCP的上下文中,
method的命名空间通常带有前缀,如tools/、resources/、prompts/,这有助于组织和分类不同的能力。理解这一点,你在阅读MCP服务器日志或调试时,就能快速定位问题发生在哪个功能模块。
3. 传输层的双通道:STDIO与Streamable HTTP详解
理解了通信的“语言”(JSON-RPC)后,我们来看“邮递方式”。MCP主要定义了两种传输方式:STDIO(标准输入输出)和Streamable HTTP。这两种方式的选择,直接决定了你如何启动、连接和运维MCP服务器。
3.1 STDIO传输:简单直接的进程间对话
STDIO是MCP协议中最常用、也是最简单的传输方式。它的模型非常直观:客户端(如Cursor)作为一个父进程,启动MCP服务器作为子进程。然后,客户端通过标准输入(stdin)向服务器发送JSON-RPC请求,服务器通过标准输出(stdout)返回响应。标准错误(stderr)通常用于输出日志信息,而非协议数据。
工作流程:
- 启动:客户端执行一条命令(例如
node ./my-mcp-server.js或python -m my_mcp_server)来启动服务器子进程。 - 绑定管道:操作系统会为这个子进程建立三个管道:stdin、stdout、stderr。客户端持有stdin的写入端和stdout的读取端。
- 通信:客户端将JSON-RPC请求字符串写入服务器的stdin。服务器从自己的stdin读取请求,处理完毕后,将JSON-RPC响应字符串写入自己的stdout,最终被客户端读取。
- 生命周期:服务器的生命周期通常与客户端绑定。客户端退出时,会终止服务器进程。
一个具体的配置示例(以Cursor配置为例):在Cursor的mcp.json配置文件中,你可能会看到这样的配置:
{ "mcpServers": { "my-file-server": { "command": "node", "args": ["/path/to/your/server/index.js"], "env": { "API_KEY": "your_secret_key_here" } } } }command指定了可执行程序(如node,python3)。args是传递给该程序的参数,第一个通常是脚本路径。env可以设置服务器进程所需的环境变量。
当Cursor启动时,它会根据这个配置,生成一个类似node /path/to/your/server/index.js的命令行,并以子进程方式运行。随后,所有与这个文件服务器的通信都通过这个进程的stdio管道进行。
STDIO模式的优缺点与调试技巧:
- 优点:
- 简单:无需管理网络端口、防火墙。
- 安全:通信完全在本地进程间进行,数据不易泄露。
- 依赖少:只要能在命令行运行,就能集成。
- 缺点:
- 紧耦合:服务器崩溃可能导致客户端不稳定,反之亦然。
- 调试输出混合:服务器的日志(stderr)和协议数据(stdout)可能混在一起,需要服务器精心设计输出。一个常见的实践是,服务器将结构化日志以JSON格式写入stderr,而仅将JSON-RPC响应写入stdout。
- 调试技巧:
- 查看原始数据流:如果你自己开发MCP服务器,一个最直接的调试方法是暂时将服务器设计成“回声模式”:把从stdin读到的每一行直接打印到stdout。然后在命令行手动运行服务器,并键入JSON-RPC消息,观察输出。
- 分离日志:确保你的服务器代码将调试信息
console.error()或sys.stderr.write(),与协议响应console.log()或sys.stdout.write()严格分开。许多MCP框架(如TypeScript的@modelcontextprotocol/sdk)已经帮你处理了这一点。 - 使用
nc(netcat)模拟:对于简单的测试,你可以用echo ‘{“jsonrpc”:”2.0”,”id”:1,”method”:”initialize”…}’ | node server.js来手动发送请求。但更复杂交互建议编写小型测试客户端。
3.2 Streamable HTTP传输:面向网络的灵活扩展
STDIO虽然简单,但它要求服务器和客户端在同一台机器上,且是父子进程关系。为了支持更灵活的部署场景,比如:
- 服务器运行在远程机器或容器中。
- 服务器是一个长期运行、服务多个客户端的独立服务。
- 希望使用现有的、基于HTTP的服务器框架。
MCP定义了Streamable HTTP传输方式。这里的“Streamable”指的是它并非简单的请求-响应,而是基于服务器发送事件(Server-Sent Events, SSE)或WebSocket(未来可能支持)的长连接、双向流式通信。目前,SSE是更主流和简单的实现。
工作流程(以SSE为例):
- 连接建立:客户端向服务器的某个特定HTTP端点(例如
http://localhost:8080/sse)发起一个GET请求,并在请求头中设置Accept: text/event-stream。这是一个持久的HTTP连接。 - 双向通信通道:
- 客户端 -> 服务器:客户端通过向另一个端点(例如
http://localhost:8080/message)发送HTTP POST请求,来传递JSON-RPC请求。请求体就是JSON-RPC对象。 - 服务器 -> 客户端:服务器通过之前建立的SSE连接,以“事件流”的形式持续向客户端推送消息。这些消息就是JSON-RPC响应或通知。每个SSE消息以
data:开头,后面跟着一个JSON字符串,最后跟两个换行符\n\n。
- 客户端 -> 服务器:客户端通过向另一个端点(例如
- 通信模型:这本质上创建了一个“半双工”通道:客户端可以随时发送请求(通过HTTP POST),服务器可以随时推送数据(通过SSE)。请求和响应通过
id字段关联。
一个Streamable HTTP服务器的简化概念代码(Node.js + Express):
const express = require('express'); const app = express(); app.use(express.json()); let clients = []; // 存储SSE客户端响应对象 // SSE连接端点 app.get('/sse', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const clientId = Date.now(); const newClient = { id: clientId, res }; clients.push(newClient); console.log(`Client ${clientId} connected`); // 发送初始化消息(例如一个通知) res.write(`data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/serverReady", params: {} })}\n\n`); req.on('close', () => { console.log(`Client ${clientId} disconnected`); clients = clients.filter(c => c.id !== clientId); }); }); // 接收客户端请求的端点 app.post('/message', (req, res) => { const jsonRpcMessage = req.body; console.log('Received:', jsonRpcMessage); // 处理请求... // 例如,处理 tools/list 请求 if (jsonRpcMessage.method === 'tools/list') { const response = { jsonrpc: "2.0", id: jsonRpcMessage.id, result: { tools: [ { name: "get_weather", description: "Get weather for a city", inputSchema: {...} } ] } }; // 将响应发送回对应的客户端(这里简化处理,广播给所有客户端) clients.forEach(client => { client.res.write(`data: ${JSON.stringify(response)}\n\n`); }); } res.status(200).end(); }); app.listen(8080, () => console.log('MCP HTTP Server listening on port 8080'));对应的客户端配置(概念性):
{ "mcpServers": { "remote-weather-server": { "url": "http://localhost:8080/sse" } } }支持Streamable HTTP的客户端(如某些Claude Code版本)会知道如何与这样的SSE端点进行交互。
Streamable HTTP的适用场景与注意事项:
- 适用场景:需要远程访问、服务器独立部署、多客户端共享、或利用现有HTTP基础设施的情况。
- 注意事项:
- 复杂性:相比STDIO,你需要自己处理HTTP服务器、路由、SSE连接管理、错误重连等,复杂度更高。
- 认证与安全:暴露HTTP端点意味着需要考虑认证(API密钥、Token)和网络安全(HTTPS),防止未授权访问。
- 连接稳定性:需要处理网络中断和自动重连逻辑。SSE本身具有重连机制,但服务器端需要妥善处理客户端列表的清理。
提示:在选择传输方式时,一个简单的原则是:优先使用STDIO。除非你有明确的远程访问、独立服务或多客户端需求,否则STDIO的简单性和安全性是本地工具集成的首选。大部分你从社区找到的MCP服务器(如文件系统操作、Git操作、搜索引擎连接器)都默认采用STDIO方式。
4. 协议握手与能力协商:initialize与notifications/initialized
当连接建立后(无论是通过STDIO还是HTTP),MCP客户端和服务器之间的第一件正事不是直接干活,而是进行一次正式的“握手”和“能力协商”。这个过程确保了双方说同一种“方言”,并了解对方能做什么、不能做什么。这是通过两个核心的JSON-RPC消息完成的:initialize请求和notifications/initialized通知。
initialize请求:客户端的自我介绍与要求这是客户端发送给服务器的第一个请求。它包含了客户端的身份信息、协议版本以及它希望服务器具备的“能力”(Capabilities)。
{ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": { "roots": { "listChanged": true }, "sampling": { "enabled": true } }, "clientInfo": { "name": "Cursor", "version": "0.40.1" } } }protocolVersion: 客户端支持的MCP协议版本。服务器需要检查自己是否兼容此版本。版本号采用日期格式,如“2024-11-05”,便于追溯。capabilities: 这是协商的核心。客户端在这里声明它希望服务器支持哪些可选的高级功能。例如:roots.listChanged: 客户端希望当服务器管理的“根目录”列表发生变化时,能收到通知。这对于文件服务器很有用,如果用户在外面新增了一个监控目录,服务器可以主动告诉客户端。sampling.enabled: 客户端支持在列出资源或工具时进行“采样”,即不一次性返回全部内容(可能巨大),而是返回一个摘要或分页结果,以提升性能。
clientInfo: 客户端软件的名称和版本,用于服务器日志和可能的差异化处理。
服务器的响应:initialize结果服务器收到initialize后,必须回复一个响应,表明自己支持哪些能力,并返回自己的信息。
{ "jsonrpc": "2.0", "id": 0, "result": { "protocolVersion": "2024-11-05", "capabilities": { "resources": { "subscribe": true }, "tools": {}, "prompts": {} }, "serverInfo": { "name": "my-file-server", "version": "1.0.0" } } }protocolVersion: 服务器实际使用的协议版本(通常与客户端一致或选择一个兼容版本)。capabilities: 服务器实际支持的能力。这是对客户端请求的“答复”。服务器可以只支持客户端请求的一部分,甚至完全不支持(返回空对象或省略)。例如,这里服务器声明它支持resources.subscribe(客户端可以订阅资源更新),但对于roots.listChanged和sampling没有回应,意味着不支持。resources,tools,prompts这些字段表明了服务器在哪些核心领域提供了功能。
serverInfo: 服务器的名称和版本。
notifications/initialized通知:握手完成的信号在成功交换initialize请求/响应后,客户端会立即发送一个通知(没有id,服务器无需回复)给服务器,宣告初始化阶段正式结束,可以开始正常工作了。
{ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} }这个看似简单的通知至关重要。它标志着一个明确的状态转换点。在收到notifications/initialized之前,服务器通常不应该处理除initialize之外的任何其他请求(如tools/list)。这防止了在能力未协商一致前就进行业务操作可能导致的错误。
为什么这个握手过程如此重要?
- 版本兼容性:确保客户端和服务器使用相互理解的协议格式,避免因版本差异导致通信失败。
- 能力发现:这是一种动态的插件机制。客户端不需要预先知道服务器有什么具体功能,通过
initialize响应中的capabilities,它就知道可以调用resources/相关的方法,还是tools/相关的方法。这使得MCP系统极具扩展性。 - 状态机清晰:
initialized通知明确了“准备就绪”的时刻,简化了服务器端的逻辑——在此之前只需处理初始化,在此之后可以安全地处理所有业务请求。
实操心得:在开发或调试MCP服务器时,最常见的启动失败原因之一就是
initialize握手失败。务必确保你的服务器能正确解析initialize请求,并返回格式正确的响应。一个常见的坑是:服务器在stdout中打印了调试日志,污染了JSON-RPC响应流,导致客户端解析失败。始终记住,只有纯粹的JSON-RPC消息才能写入stdout。
5. 核心交互模型:资源(Resources)、工具(Tools)与提示词(Prompts)
握手成功后,MCP会话就进入了核心工作阶段。MCP协议定义了三种主要的交互模型,对应三种核心概念:资源(Resources)、工具(Tools)和提示词(Prompts)。理解这三者的区别和用途,是灵活运用MCP的关键。
5.1 资源(Resources):只读信息的提供者
资源,可以理解为服务器向客户端暴露的只读数据流。它的核心思想是“订阅-通知”。客户端不是每次需要数据时都去请求,而是先订阅感兴趣的资源,当资源内容发生变化时,服务器会主动通知客户端。
典型场景:
- 文件内容:一个文件服务器可以将本地目录的文件作为资源暴露。客户端订阅
file:///path/to/doc.md,当文件被修改时,服务器通知客户端内容已更新。 - 数据库查询结果:一个数据库服务器可以将某个查询视图作为资源暴露。
- 实时数据:股票价格、天气信息、系统监控指标等。
核心交互方法:
resources/list: 客户端请求服务器列出所有可用的资源或资源模板。服务器返回一个资源列表,每个资源包含uri(唯一标识,如file:///...)、name、description和mimeType(如text/markdown)。resources/subscribe: 客户端订阅一个或多个资源。请求中给出资源URI列表。resources/unsubscribe: 客户端取消订阅。notifications/resources/updated: 服务器主动发送的通知,告知客户端某个已订阅资源的内容已更新。通知中会包含新的资源内容。
资源模型的优势在于其实时性和效率。对于变化的数据,客户端无需轮询,减少了不必要的请求,并能即时获取最新信息。这对于需要将最新上下文提供给AI模型的场景非常有用。
5.2 工具(Tools):可执行操作的触发器
工具,是MCP中最常用、最直观的交互模型。它代表了服务器能够执行的具体操作。客户端(即AI助手)可以列出所有可用工具,然后根据用户的需求,选择并调用合适的工具。
典型场景:
- 执行命令:在终端中运行一条命令。
- 网络请求:执行一个Web搜索、调用一个API。
- 数据操作:对数据库进行增删改查、处理一段文本。
- 系统交互:发送邮件、操作剪贴板。
核心交互方法:
tools/list: 客户端请求列出所有可用工具。服务器返回一个工具列表,每个工具必须定义:name: 工具的唯一标识符。description: 人类可读的描述,AI模型主要依靠这个描述来决定是否以及如何调用该工具。inputSchema: 一个遵循JSON Schema格式的定义,详细描述了调用此工具时需要提供的参数(类型、格式、是否必填等)。这是最关键的部分,一个清晰、准确的inputSchema直接决定了AI模型能否正确使用你的工具。
tools/call: 客户端调用一个工具。请求中需指定工具name和对应的arguments(参数对象)。服务器执行工具逻辑,并返回结果。notifications/tools/call: 这是一个进度通知。对于执行时间较长的工具(如训练模型、下载大文件),服务器可以通过此通知向客户端发送进度更新,提升用户体验。
一个工具定义的详细示例:
{ "name": "search_web", "description": "Searches the web for current information using a search engine. Useful for finding recent events, news, or specific facts not in the model's training data.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query string." }, "num_results": { "type": "integer", "description": "Number of search results to return (default: 5, max: 10).", "default": 5, "minimum": 1, "maximum": 10 } }, "required": ["query"] } }当AI模型(如Claude)看到这个工具描述和输入模式后,它就能理解:当用户问“今天硅谷有什么科技新闻?”时,它应该调用search_web工具,并生成一个类似{"query": "硅谷 科技新闻 今日", "num_results": 5}的参数对象。
工具调用的完整流程示例:
- 用户向AI助手提问:“帮我查一下OpenAI最新发布的模型。”
- AI助手(客户端)向MCP服务器发送
tools/list请求,获取工具列表。 - 服务器返回包含
search_web工具的定义。 - AI助手分析用户问题,决定调用
search_web工具,并构造参数{"query": "OpenAI 最新 发布 模型 2024"}。 - AI助手发送
tools/call请求。 - 服务器执行搜索逻辑(可能调用外部API),获取结果。
- 服务器返回
tools/call响应,结果中包含搜索到的网页摘要或链接列表。 - AI助手将搜索结果整合到自己的回复中,呈现给用户。
5.3 提示词(Prompts):预置对话模板
提示词是MCP中相对较新的概念,它允许服务器向客户端提供预定义的、结构化的对话模板或指令集。客户端可以获取这些提示词,并将其作为上下文的一部分注入与AI模型的对话中,从而引导模型以特定的风格、角色或流程进行交互。
典型场景:
- 代码审查模板:一个提示词定义了进行代码审查时应检查的步骤和要点。
- 写作助手角色:一个提示词将AI设定为一位专业的科技文章编辑。
- 复杂任务拆解指南:一个提示词指导AI如何将“开发一个简单网页”的任务分解成若干步骤。
核心交互方法:
prompts/list: 客户端请求列出所有可用提示词。prompts/get: 客户端根据提示词名称获取其详细内容。
提示词与工具/资源的区别在于,它不直接执行操作或提供数据,而是提供元指令,影响AI模型本身的“行为模式”。它扩展的是模型的“认知”或“角色”上下文,而非其“行动”能力。
三者关系总结:
- 资源(Resources)是关于“是什么/当前状态”的信息提供者(被动,只读,可订阅)。
- 工具(Tools)是关于“做什么”的操作执行者(主动,可调用,有输入输出)。
- 提示词(Prompts)是关于“如何思考/扮演什么角色”的行为引导者(元层级,影响模型本身)。
一个功能强大的MCP服务器往往会同时提供多种能力。例如,一个“开发者助手”服务器可能:
- 提供
file资源,让AI能读取项目文件。 - 提供
run_shell工具,让AI能执行构建命令。 - 提供
code_review提示词,让AI能以代码审查专家的角色进行对话。
6. 实战:从零构建一个简单的MCP服务器(STDIO版本)
理论说得再多,不如亲手实践。现在,让我们用Node.js从零构建一个最简单的MCP服务器,它只提供一个功能:一个名为get_time的工具,调用后返回当前服务器时间。我们将使用官方的@modelcontextprotocol/sdk包来简化协议处理。
第一步:项目初始化与依赖安装
mkdir simple-mcp-time-server cd simple-mcp-time-server npm init -y npm install @modelcontextprotocol/sdk创建一个package.json文件,确保type字段是"module"或我们使用CommonJS。这里我们使用ES Modules。
第二步:编写服务器代码 (server.js)
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; // 1. 创建Server实例 const server = new Server( { name: 'simple-time-server', version: '1.0.0', }, { capabilities: { tools: {}, // 声明我们支持tools功能 }, } ); // 2. 定义我们的工具:get_time server.setRequestHandler('tools/list', async () => { return { tools: [ { name: 'get_time', description: 'Get the current server time in ISO format.', inputSchema: { type: 'object', properties: {}, // 这个工具不需要参数 required: [], }, }, ], }; }); // 3. 处理工具调用 server.setRequestHandler('tools/call', async (request) => { const { name, arguments: args } = request.params; if (name !== 'get_time') { throw new Error(`Unknown tool: ${name}`); } // 实际执行逻辑:获取当前时间 const currentTime = new Date().toISOString(); return { content: [ { type: 'text', text: `The current server time is: ${currentTime}`, }, ], }; }); // 4. 错误处理(可选但推荐) server.setRequestHandler('error', async (error) => { console.error('[MCP Server Error]', error); }); // 5. 启动服务器,使用STDIO传输 async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('Simple Time MCP Server running via STDIO...'); } main().catch((error) => { console.error('Failed to start server:', error); process.exit(1); });代码逐段解析:
- 导入与创建Server:我们从SDK导入核心的
Server类和用于STDIO传输的StdioServerTransport。创建Server时需要提供serverInfo(名称和版本)和初始的capabilities(这里我们声明支持tools)。 - 注册
tools/list处理器:当客户端请求工具列表时,我们返回一个包含get_time工具定义的数组。注意inputSchema是一个空对象,表示此工具无需参数。 - 注册
tools/call处理器:这是核心业务逻辑。我们检查调用的工具名是否是get_time,然后执行获取当前时间的操作,并按照MCP协议要求的格式返回结果。content数组中的type: 'text'是标准格式。 - 错误处理:注册一个通用的错误处理器,将错误日志打印到
stderr(这是调试信息,不会干扰协议通信)。 - 启动与连接:创建
StdioServerTransport实例,让Server与之连接,然后开始监听stdin。
第三步:测试服务器首先,确保你的package.json中指定了入口文件,或者直接运行:
node server.js此时,服务器会启动并等待stdin的输入。它不会主动退出,因为它在等待客户端的连接。
如何手动测试?我们可以写一个极简的测试客户端脚本 (test_client.js) 来模拟Cursor的行为:
import { spawn } from 'child_process'; const serverProcess = spawn('node', ['server.js']); // 处理服务器输出 (stdout) serverProcess.stdout.on('data', (data) => { console.log('[Server Response]', data.toString()); }); // 处理服务器错误输出 (stderr) serverProcess.stderr.on('data', (data) => { console.error('[Server Log]', data.toString()); }); // 发送 initialize 请求 const initializeRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'TestClient', version: '1.0' } } }; serverProcess.stdin.write(JSON.stringify(initializeRequest) + '\n'); // 稍等片刻,然后发送 tools/list 请求 setTimeout(() => { const listRequest = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }; serverProcess.stdin.write(JSON.stringify(listRequest) + '\n'); }, 100); // 再稍等,然后调用 get_time 工具 setTimeout(() => { const callRequest = { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'get_time', arguments: {} } }; serverProcess.stdin.write(JSON.stringify(callRequest) + '\n'); }, 200); // 10秒后结束测试 setTimeout(() => { serverProcess.kill(); process.exit(0); }, 10000);运行node test_client.js,你应该能看到服务器返回的initialize响应、tools/list响应以及包含当前时间的tools/call响应。
第四步:集成到Cursor
- 将你的
server.js脚本放在一个固定位置。 - 在Cursor的配置目录(通常是
~/.cursor/mcp.json或项目根目录的.cursor/mcp.json)中添加配置:
{ "mcpServers": { "simple-time": { "command": "node", "args": ["/绝对路径/to/your/simple-mcp-time-server/server.js"] } } }- 重启Cursor。在Chat界面,你应该能直接问AI助手:“现在服务器时间是多少?”,AI助手会自动发现并使用
get_time工具来回答你。
通过这个简单的例子,你不仅看到了一个MCP服务器的完整骨架,更重要的是,你理解了协议消息是如何在底层流动的:从initialize握手,到tools/list发现,再到tools/call执行。当你需要开发更复杂的服务器(比如连接数据库、调用API)时,只需要在这个骨架上,丰富tools/list返回的工具定义,并在tools/call处理器中实现更复杂的业务逻辑即可。
7. 高级主题与协议边界探讨
在掌握了MCP协议的基础通信模型和核心交互方式后,我们可以进一步探讨一些更深入的话题和实践中可能遇到的边界情况。这些知识能帮助你在更复杂的场景下游刃有余。
7.1 资源订阅(Subscription)的深层机制与实现
资源模型的核心是“订阅”。但订阅是如何工作的?服务器如何知道哪个客户端订阅了哪个资源?协议本身没有规定服务器端的状态管理方式,这留给了实现者。
一种常见的实现模式是“主题-订阅者”模式:
- 服务器维护一个
Map<resourceUri, Set<clientId>>,记录每个资源被哪些客户端订阅。 - 当客户端A发送
resources/subscribe请求,订阅了file:///a.txt和file:///b.txt。 - 服务器在内部映射表中记录:
a.txt -> [clientA],b.txt -> [clientA]。 - 当
a.txt文件发生变化时(通过文件系统监听器fs.watch或轮询检测到),服务器遍历a.txt对应的客户端集合[clientA],向每个客户端发送一个notifications/resources/updated通知,包含新的内容。 - 客户端收到通知后,就可以更新其内部缓存或直接通知AI模型上下文已更新。
关键点:
- 订阅是持久的:直到客户端断开连接或显式调用
unsubscribe,订阅一直有效。 - 通知是异步的:服务器可以在任何时间点发送
updated通知。 - 内容传递:
updated通知可以直接包含资源的完整新内容(对于小资源),也可以只包含一个URI和提示,让客户端在需要时再通过resources/read来读取(对于大资源)。协议允许这两种方式。
实现订阅的挑战:
- 状态管理:对于HTTP传输,服务器需要将SSE连接与客户端ID关联起来。
- 资源变更检测:对于文件系统,需要可靠的文件监听机制;对于数据库或API,可能需要轮询或监听事件总线。
- 性能考量:当资源数量多或客户端数量多时,映射表的管理和通知的广播需要优化。
7.2 错误处理与协议兼容性策略
MCP协议基于JSON-RPC 2.0,因此继承了其错误处理机制。但作为服务器开发者,你需要有策略地处理各类错误。
标准JSON-RPC错误码在MCP中的含义:
-32700解析错误:客户端发送的JSON格式无效。检查你的stdout输出是否被日志污染。-32600无效请求:请求对象结构不对,缺少必需字段。-32601方法未找到:客户端调用了服务器未声明的method(例如,你的服务器只支持tools/,但客户端调用了resources/list)。在initialize响应中准确声明capabilities可以避免此问题。-32602无效参数:params不符合预期。这是工具调用中最常见的错误。原因通常是inputSchema定义不够严格,或者AI模型生成的参数格式有误。在tools/call处理器中,应首先严格校验参数。-32603内部错误:服务器在处理请求时发生了未预期的异常。应在stderr记录详细日志,并给客户端返回友好的错误信息(可放在error.data字段)。
协议版本兼容性:MCP协议版本号(如2024-11-05)可能会引入不兼容的变更。你的服务器在initialize阶段应检查客户端传来的protocolVersion。
- 如果客户端版本低于服务器支持的最低版本,可以返回错误。
- 如果客户端版本更高,服务器应尽量保持向后兼容,只使用自己支持的功能子集,并在
capabilities中如实告知。 - 一种稳健的策略是:服务器支持一个主要的协议版本,并忽略无法识别的客户端请求字段(遵循JSON-RPC的“忽略未知成员”原则)。
7.3 性能、安全与生产环境考量
性能优化:
- 工具描述的缓存:
tools/list和resources/list的响应内容在服务器运行期间通常不变。可以在内存中缓存这些响应,避免每次请求都重新生成。 - 大资源的分页与采样:如果
resources/list可能返回成千上万个资源(如整个大文件系统),应利用sampling能力,只返回部分样本或支持分页查询(如果协议版本支持)。 - 异步与非阻塞操作:工具调用(如网络请求、复杂计算)应该是异步的,避免阻塞主线程,影响其他请求的处理。使用
async/await或 Promise。
安全加固:
- 参数验证与净化:对于从客户端接收的任何参数(特别是工具参数),必须进行严格的验证和净化,防止注入攻击。例如,如果工具是执行Shell命令,绝对不要直接将用户输入拼接成命令,应使用参数化调用。
- 权限控制:服务器应实现最小权限原则。例如,一个文件服务器可以配置允许访问的根目录,防止客户端通过
file:///../../etc/passwd这样的URI访问系统文件。 - 传输安全:对于Streamable HTTP,务必使用HTTPS,并对请求进行认证(如API Key验证)。可以在HTTP服务器的入口中间件中验证请求头中的Token。
生产环境部署:
- 进程管理:对于STDIO服务器,客户端(如Cursor)负责进程生命周期。但对于HTTP服务器,你需要使用进程管理器(如PM2、systemd)来保证其常驻运行,并处理崩溃重启。
- 日志与监控:将详细的日志(尤其是错误日志)输出到文件或日志收集系统(如ELK)。监控服务器的资源使用情况和请求延迟。
- 配置化:将服务器地址、API密钥、允许的根目录等配置项外部化(通过环境变量或配置文件),而不是硬编码在代码中。
拆解MCP协议,从理解JSON-RPC的每个字段,到选择STDIO还是HTTP,再到实现资源、工具、提示词这些核心模型,最后考虑生产环境的打磨,这个过程本身就是一次从“使用者”到“构建者”的思维升级。当你再看到一段MCP配置,或者遇到一个连接错误时,你脑海中浮现的不再是模糊的概念,而是清晰的协议消息流、握手步骤和状态转换。这份清晰感,正是拆开“黑盒”所赋予你的最大价值。它让你不仅能解决问题,更能预见问题,甚至创造新的解决方案。