mcp的研习

时间:2026-07-07 08:24:46 来源:互联网

MCP(Model Context Protocol)正成为AI领域的热门话题,它让模型具备实际操作能力。本文将带您从零学习MCP核心概念、通信流程与常见陷阱,通过手写一个文件读取服务来完全搞懂这个协议。

前言

最近MCP在AI圈引发广泛关注,大家热烈讨论如何让AI真正动手干活。我花费时间研读了MCP的SDK源码,并手写了一个文件读取的MCP Server。本文会从零开始,带您彻底理解MCP的核心概念、通信流程以及那些容易踩的坑。

读完本文,你将收获:

  1. MCP协议的核心设计思想(用生活化比喻讲透)
  2. 手写MCP Server的完整流程(附详细注释)
  3. 完整调用链路拆解(从用户提问到AI回答,每一步都讲清楚)
  4. 源码级别的重难点剖析(为什么这么写?)
  5. 面试高频考点与避坑指南

一、MCP是什么?先讲个故事

1.1 没有MCP的AI:一个语言上的巨人

想象您请了一个超级助理(AI),他能听懂您说的一切,但有个致命缺陷:他只能动嘴,不能动手。

text

 复制代码您:帮我把D盘里的config.json读出来看看
AI:好的,我建议你双击打开文件,然后...
您:???

这就是传统AI的困境——信息孤岛。模型再强大,也摸不到您的文件系统、数据库、API服务。

1.2 MCP出场:AI的万能USB接口

MCP要解决的就是这个问题,它定义了一套标准化的协议,让AI能够通过统一的接口调用外部工具。

用个接地气的比喻:

现实世界MCP世界
你想买房AI想读取文件
房产中介MCP协议(中间人)
房源信息文件内容
房东您的MCP Server

二、完整代码解读:一个文件读取MCP Server

2.1 完整源码(带超详细注释)

javascript

 复制代码// 1️⃣ 导入MCP SDK核心模块
import { Server } from '@modelcontextprotocol/sdk/server';
// 本地stdio通信传输层
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// MCP协议定义的事件类型
import {
    ListToolsRequestSchema,  // 列出工具事件
    CallToolRequestSchema    // 调用工具事件
} from '@modelcontextprotocol/sdk/types.js';
// Node.js文件系统(Promise版本)
import fs from 'fs/promises';// 2️⃣ 创建MCP Server实例
// 重点:这里不是普通的服务器,而是MCP协议的实现者
const server = new Server(
    { 
        name: 'simple-read-mcp',     // Server唯一标识
        version: '1.0.0'              // 语义化版本
    },
    { 
        capabilities: { tools: {} }   // 声明能力:提供工具
    }
);// 3️⃣ 注册列出工具处理器
// 当Client(AI)询问你会什么时触发
server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
        tools: [
            {
                name: 'read_file',                    // 工具名称
                description: '读取指定路径的本地文件内容',  // 工具描述
                inputSchema: {                        // 参数定义(JSON Schema)
                    type: 'object',
                    properties: {
                        path: {
                            type: 'string',
                            description: '文件的绝对或相对路径'
                        }
                    },
                    required: ['path']                // 必填参数
                }
            }
        ]
    };
});// 4️⃣ 注册调用工具处理器
// 当Client说执行read_file时触发
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    // 路由分发:从请求中解构出工具名和参数
    // 注意:arguments是保留字,重命名为args
    const { name, arguments: args } = request.params;
    
    // 判断要调用哪个工具(未来可以扩展多个if-else或switch)
    if (name === 'read_file') {
        try {
            // 核心业务逻辑:读取文件
            const content = await fs.readFile(args.path, 'utf-8');
            
            // 返回成功结果(上下文会传给LLM)
            return {
                content: [
                    { type: 'text', text: content }
                ]
            };
        } catch (error) {
            // 返回错误信息(标记isError)
            return {
                isError: true,
                content: [
                    { type: 'text', text: error.message }
                ]
            };
        }
    }
    
    // 未知工具抛出错误
    throw new Error(`未知工具: ${name}`);
});// 5️⃣ 启动服务:建立stdio通信连接
async function main() {
    // 创建stdio传输层(通过stdin/stdout通信)
    const transport = new StdioServerTransport();
    // 将Server绑定到传输层,开始监听消息
    await server.connect(transport);
    // 注意:connect之后进程会阻塞,持续监听
}main();

2.2 代码结构一览

img_6a4c474e5d9a130.webp


三、核心难点:完整调用链路拆解

3.1 一个请求的完整生命周期

这是本文的灵魂部分!我们以用户提问读取D盘的config.json为例,逐步拆解每一步发生了什么:

img_6a4c474e6e55931.webp

3.2 逐帧拆解:从CC Prompt到LLM Generate

我们来把上面流程图中最核心的这条链路拆解开:

text

 复制代码cc prompt -> llm -> 选择fs client -> stdioServerTransport -> stdin -> server -> 执行返回 -> stdout -> stdioClientTransport -> cc -> llm -> generate

第一步:cc prompt(用户在Claude Desktop输入)

javascript

 复制代码// 用户在Claude Desktop的输入框中敲下:
"帮我读取D盘根目录下的config.json文件"

此时Claude Desktop(Host)只是接收了文本,还没有任何处理。

第二步:llm(发送给AI模型)

javascript

 复制代码// Claude Desktop将用户消息封装成API请求,发送给Claude API
// 请求中包含了系统提示词(System Prompt),其中注入了MCP工具信息

关键点: 系统提示词中会包含类似这样的内容:

text

 复制代码你是一个AI助手,你可以使用以下工具:
- read_file: 读取指定路径的本地文件内容
  参数: path (string) - 文件的绝对或相对路径当用户需要读取文件时,请调用这个工具。

第三步:选择fs client(LLM决定调用工具)

javascript

 复制代码// Claude模型分析后,决定调用read_file工具
// 模型输出的不是自然语言,而是函数调用指令:{
    "name": "read_file",
    "arguments": {
        "path": "D:config.json"
    }
}

这是关键转折点: AI从生成文本模式切换到调用工具模式。

第四步:stdioServerTransport(Client发送请求)

javascript

 复制代码// Claude Desktop将工具调用指令打包成JSON-RPC消息
const request = {
    jsonrpc: "2.0",
    method: "call_tool",
    params: {
        name: "read_file",
        arguments: { path: "D:config.json" }
    },
    id: 1
};// 通过stdio写入子进程的stdin
process.stdin.write(JSON.stringify(request));

第五步:stdin -> server(Server接收请求)

javascript

 复制代码// 您的MCP Server一直在监听stdin
// server.connect(transport)内部启动了消息监听循环// 当收到消息后,SDK自动解析JSON-RPC,识别出method是call_tool
// 触发您注册的CallToolRequestSchema处理器server.setRequestHandler(CallToolRequestSchema, async (request) => {
    // request.params.name === 'read_file'
    // request.params.arguments === { path: 'D:config.json' }
    // 开始执行业务逻辑...
});

第六步:执行返回(Server执行业务逻辑)

javascript

 复制代码// 您的代码调用Node.js的fs模块
const content = await fs.readFile('D:config.json', 'utf-8');
// content = '{ "name": "my-app", "version": "1.0.0" }'// 将结果打包成JSON-RPC响应
const response = {
    jsonrpc: "2.0",
    result: {
        content: [
            { type: "text", text: '{ "name": "my-app", "version": "1.0.0" }' }
        ]
    },
    id: 1
};

第七步:stdout(Server返回结果)

javascript

 复制代码// Server通过stdout写入响应
process.stdout.write(JSON.stringify(response));

第八步:stdioClientTransport(Client接收响应)

javascript

 复制代码// Claude Desktop监听子进程的stdout
// 收到响应后,解析JSON-RPC,提取result.contentconst result = {
    content: '{ "name": "my-app", "version": "1.0.0" }'
};

第九步:cc -> llm(Client将结果交给LLM)

javascript

 复制代码// Claude Desktop将工具执行结果作为上下文,再次调用Claude API
// 这次请求中包含了工具返回的文件内容const messages = [
    { role: "user", content: "帮我读取D盘根目录下的config.json文件" },
    { role: "assistant", content: null, tool_calls: [...] },
    { role: "tool", tool_call_id: 1, content: '{ "name": "my-app", "version": "1.0.0" }' }
];

第十步:llm -> generate(LLM生成最终回答)

javascript

 复制代码// Claude拿到文件内容后,开始生成最终回答
// 这次生成的是自然语言文本const finalAnswer = "我已经读取了D:config.json文件,内容如下:nn```jsonn{  "name": "my-app",  "version": "1.0.0"}n```";// 显示在Claude Desktop的界面上

3.3 完整数据流图

img_6a4c474e7f0ea32.webp

img_6a4c474e8d25333.webp


四、重难点深度剖析

4.1 难点一:为什么Server实例必须用MCP SDK创建?

很多新手会问:我直接用Express写个API不行吗?为什么非要搞个new Server()?

设计者的考量:

MCP不是普通的HTTP API,而是一个协议层面的标准。所有MCP Server必须遵循相同的通信规范(JSON-RPC 2.0)、握手流程、错误格式。

如果用Express:

javascript

 复制代码// 错误示范:普通HTTP API
app.get('/read-file', (req, res) => {
    res.json({ content: fs.readFileSync(req.query.path) });
});// 问题:
// 1. AI不知道有这个API(没有工具发现机制)
// 2. 调用方式不统一(每个API都要单独写调用代码)
// 3. 没有权限控制标准
// 4. 不在MCP生态内,无法被AI自动发现

而使用MCP SDK:

javascript

 复制代码// 正确示范:MCP Server
const server = new Server(
    { name: 'my-server', version: '1.0.0' },
    { capabilities: { tools: {} } }
);// 优势:
// 1. AI通过list_tools自动发现
// 2. 统一的JSON-RPC调用方式
// 3. 标准的错误处理
// 4. 开箱即用的协议实现

攻克思路:

把new Server()理解为给你的程序注入MCP基因。SDK内部已经实现了:

  1. JSON-RPC消息解析
  2. 协议版本协商
  3. 生命周期管理
  4. 标准错误格式

您只需要关注业务逻辑(read_file怎么实现)。


4.2 难点二:ListToolsRequestSchema和CallToolRequestSchema的设计模式

这两个Handler看起来很简单,背后其实是经典的请求-路由模式。

为什么分两个处理器?

处理器触发时机职责类比
ListToolsRequestSchemaClient初始化/询问时展示菜单餐厅菜单
CallToolRequestSchemaClient具体调用时执行点单厨师做菜

为什么这么设计?

这是关注点分离原则的体现:

  1. ListToolsRequestSchema负责元数据管理(有什么工具)
  2. CallToolRequestSchema负责业务执行(怎么实现)

这样当您的工具从1个扩展到100个时,Client的调用逻辑完全不用改。


4.3 难点三:arguments为什么要重命名为args?

javascript

 复制代码const { name, arguments: args } = request.params;

很多新手会疑惑:为什么要多此一举?

原因有两个:

  1. arguments是JavaScript的保留字(虽然在严格模式下可用,但不是好习惯)
  2. 避免混淆:arguments在函数中代表类数组对象,重命名后更清晰

面试可能会问: 如果不用重命名会怎样?

javascript

 复制代码// 错误示范(虽然能跑)
const { name, arguments } = request.params;  // 语法警告// 正确示范
const { name, arguments: args } = request.params;

其实这个细节透露了设计者对代码健壮性的考量——即使将来JavaScript严格模式发生变化,这段代码也不会受影响。


4.4 难点四:为什么使用stdio而不是HTTP?

设计考量:

特性stdioHTTP
进程通信本地进程间直接通信需要网络协议栈
端口管理无需管理端口需要处理端口冲突
防火墙不受影响可能需要配置
跨平台所有平台支持依赖网络配置
安全性天然隔离需要额外安全措施

最适合的场景: AI应用和工具在同一台机器上运行时,stdio是最简单、最可靠的选择。


五、避坑指南(新手必看)

坑1:在stdio模式下使用console.log()

错误做法:

javascript

 复制代码server.setRequestHandler(CallToolRequestSchema, async (request) => {
    console.log('收到请求:', request);  // 这会破坏通信!
    // ...
});

原因: 在stdio模式下,stdout被用作JSON-RPC通信通道。任何非JSON格式的输出都会导致Client解析失败。

正确做法:

javascript

 复制代码// 使用stderr输出调试信息(不影响通信)
console.error('收到请求:', request);

坑2:忘记处理路径遍历攻击

危险代码:

javascript

 复制代码const content = await fs.readFile(args.path, 'utf-8');  // 任何路径都能读!

AI可能被诱导读取敏感文件:

  1. ../../../../etc/passwd
  2. C:WindowsSystem32driversetchosts
  3. ~/.ssh/id_rsa

安全做法:

javascript

 复制代码import path from 'path';const ALLOWED_ROOT = process.env.MCP_ROOT_PATH || process.cwd();server.setRequestHandler(CallToolRequestSchema, async (request) => {
    if (name === 'read_file') {
        // 1. 解析绝对路径
        const realPath = path.resolve(args.path);
        const realRoot = path.resolve(ALLOWED_ROOT);
        
        // 2. 校验路径是否在允许范围内
        if (!realPath.startsWith(realRoot)) {
            return {
                isError: true,
                content: [{ 
                    type: 'text', 
                    text: `访问超出允许范围: ${args.path}` 
                }]
            };
        }
        
        // 3. 安全读取
        const content = await fs.readFile(realPath, 'utf-8');
        // ...
    }
});

坑3:进程意外退出

问题: 如果main()函数执行完毕,进程会退出,Server就不可用了。

javascript

 复制代码// 错误:main函数执行完就退出
function main() {
    const transport = new StdioServerTransport();
    server.connect(transport);
    // 没有await,进程立即退出
}

正确做法:

javascript

 复制代码// 正确:用async/await保持进程运行
async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    // connect内部会保持进程运行
}

六、面试高频考点

考点1:MCP的三个核心作用是什么?

回答要点:

作用具体内容类比
① 统一通信格式规定用JSON-RPC 2.0作为消息格式规定大家都说普通话,而不是各说各的方言
② 统一数据类型用JSON Schema定义数据结构规定苹果就是那种红色的水果,而不是有人叫它apple有人叫它林檎
③ 统一交互流程定义完整的生命周期:初始化→发现工具→调用工具→关闭规定社交礼仪:见面先握手,再说话,说完再见

加分回答: MCP不是业务逻辑层,也不是界面层,它是协议层——定义了Client和Server之间如何沟通的游戏规则。

考点2:MCP和普通API的区别是什么?

回答要点:

  1. 标准化:MCP是协议层面的标准,所有实现互通;普通API是各自为政
  2. 工具发现:MCP有list_tools机制,AI自动发现;普通API需要人工文档
  3. 调用方式:MCP通过JSON-RPC统一调用;普通API五花八门(REST、gRPC等)
  4. AI原生:MCP专为AI设计,支持自然语言驱动;普通API需要人工编码调用

考点3:ListToolsRequestSchema和CallToolRequestSchema的处理顺序?

回答要点:

  1. 初始化阶段:Client启动时会先调用list_tools,获取所有工具定义
  2. 决策阶段:LLM根据工具列表和用户需求,决定调用哪个工具
  3. 执行阶段:Client发送call_tool请求,携带工具名和参数
  4. 结果返回:Server执行后返回结果,Client再转交给LLM

关键理解: list_tools是元数据查询,call_tool是实际操作。先发现,后调用。


七、总结:MCP的三个核心作用

通过上面的完整代码和调用链路分析,我们可以把MCP的核心作用归纳为三个统一:

作用具体内容生活类比
① 统一通信格式规定使用JSON-RPC 2.0作为消息格式规定大家都说普通话,而不是各说各的方言
② 统一数据类型用JSON Schema定义工具参数结构规定苹果就是那种红色的水果,而不是有人叫它apple有人叫它林檎
③ 统一交互流程定义完整的生命周期:初始化→发现工具→调用工具→关闭规定社交礼仪:见面先握手,再说话,说完再见

对应到我们的代码

javascript

 复制代码// ① 统一通信格式
// Server和Client之间传递的是JSON-RPC 2.0消息
// 例如Client发来的请求:
{
    "jsonrpc": "2.0",
    "method": "call_tool",
    "params": { "name": "read_file", "arguments": { "path": "test.txt" } },
    "id": 1
}// ② 统一数据类型
// 工具参数用inputSchema(JSON Schema)定义
inputSchema: {
    type: 'object',
    properties: {
        path: { type: 'string' }  // 明确告诉对方:path必须是字符串
    },
    required: ['path']            // 告诉对方:path必须传
}// ③ 统一交互流程
// 先list_tools(发现)→再call_tool(调用)
server.setRequestHandler(ListToolsRequestSchema, ...);  // 第一步:展示菜单
server.setRequestHandler(CallToolRequestSchema, ...);   // 第二步:执行点单

回到你的理解

完全正确! 更准确地说:

它不负责具体的业务逻辑(那是Server的事),也不负责UI和LLM调用(那是Host/Client的事),它只负责一件事:

用一张图来总结MCP的定位:

img_6a4c474e9dd3734.webp

记住一句话:MCP不是业务逻辑层,也不是界面层,它是协议层——定义了Client和Server之间如何沟通的游戏规则。

就像HTTP之于Web浏览器和服务器,MCP之于AI应用和工具服务,本质上是沟通规则的标准化。