导语

之前已经就MCP的基础概念做了简单介绍,今天我们进一步深入了解MCP:

  1. 写一个 MCP server
  2. 分析 MCP 底层协议
  3. MCP 含义与地位

创建 MCP server

创建 MCP server 语言有很多: pythonnodejavac#等。

mcp-server

接下来我们按照 Model Context Protocol 进行简单的实战演练。

Model Context Protocol

1. 按照教程步骤执行

会生成这样一个简单的项目工程:

1
2
3
4
5
6
7
├── weather
├── build # 静态资源
│ └── index.js # html模板
├── src # 源代码
│ └── index.ts # 入口文件
├── tsconfig # ts 配置
└── package.json # package.json

2. 接下来打开 vscode,按图中步骤执行:

vscode要事先安装好 cline 插件

steps

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"mcpServers": {
"weather": {
"disabled": false,
"timeout": 300,
"type": "stdio",
"command": "node",
"args": [
"/Users/zhangsan/Downloads/Code/cline/weather/build/index.js"
]
}
}
}

3. 新建 cline 会话

点击 +
new.png

4. 询问天气

例如:你好,北京的天气怎么样

s1.png

点击 Approve

s2.png

MCP 底层协议

我们已经创建了一个 MCP server。但是我们仍然不知道 MCP 协议到底是如何运作的。

MCP server虽然是我们写的,但是我们用了 MCP 库来完成它的,它帮我们完成了很多事情。(我们并不清楚他到底做了什么)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"

为了搞清楚 cline 与 MCP server 是如何沟通的,我们在二者沟通中间加入一个日志脚本 mcp-stdio-logger.cjs

日志脚本

创建 mcp-stdio-logger.cjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// mcp-stdio-logger.cjs
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');

// 从命令行参数获取要运行的实际脚本路径 和 服务器名称
// 例如:node mcp-stdio-logger.cjs /path/to/my/server/index.js weather
const targetScriptPath = process.argv[2];
const serverName = process.argv[3]; // 新增的参数:MCP Server 的名称 (e.g., "weather")

if (!targetScriptPath || !serverName) {
console.error('错误: 未提供目标脚本路径或服务器名称。用法: node mcp-stdio-logger.cjs <path_to_your_index.js> <server_name>');
process.exit(1);
}

// 确定日志文件的路径
// 日志文件与目标脚本在同级目录,且文件名基于服务器名称
const logDir = path.dirname(targetScriptPath);
const logFileName = `mcp_interaction_log_${serverName}.json`; // 使用服务器名称作为日志文件名
const logFilePath = path.join(logDir, logFileName);

let logs = []; // 用于存储所有交互日志的数组 (包括历史和本次运行的)

// --- 辅助函数:加载现有日志 ---
function loadExistingLogs() {
if (fs.existsSync(logFilePath)) {
try {
const fileContent = fs.readFileSync(logFilePath, 'utf8');
// 确保文件内容不为空,且是有效的 JSON 数组
if (fileContent.trim().length > 0) {
const parsed = JSON.parse(fileContent);
if (Array.isArray(parsed)) {
logs = parsed;
console.log(`[MCP Logger] 已加载现有 ${logs.length} 条日志到内存。`);
} else {
console.warn(`[MCP Logger] 现有日志文件 ('${logFilePath}') 内容不是有效的 JSON 数组,将重新创建日志。`);
logs = []; // 如果不是数组,清空并从一个空的日志数组开始
}
} else {
console.log(`[MCP Logger] 现有日志文件 ('${logFilePath}') 为空,从头开始记录。`);
}
} catch (err) {
console.error(`[MCP Logger] 加载现有日志文件 ('${logFilePath}') 失败:`, err.message);
// 如果解析失败,可能是文件损坏或格式错误,我们从一个空的日志数组开始,并会覆盖旧文件
logs = [];
}
} else {
console.log(`[MCP Logger] 日志文件 ('${logFilePath}') 不存在,将创建新文件。`);
}
}

// --- 辅助函数:记录日志 ---
function logInteraction(type, data, source = 'unknown') {
const entry = {
id: logs.length + 1, // 为每条日志添加一个自增ID,方便追踪
timestamp: new Date().toISOString(),
type: type, // 'input_from_cline', 'output_to_cline', 'child_stderr'
source: source,
data: data.toString('utf8') // 假设数据是 UTF-8 编码
};
logs.push(entry);
// 可以在这里选择性地输出到控制台,方便实时查看
// console.log(`[${type}] ${source}: ${data.toString('utf8').trim()}`);
}

// --- 辅助函数:写入日志到文件 ---
function writeLogsToFile() {
if (logs.length === 0) {
console.log(`[MCP Logger] 没有交互日志需要写入文件。`);
return;
}
console.log(`\n[MCP Logger] 正在将 ${logs.length} 条交互日志写入文件: ${logFilePath}`);
try {
// 使用 fs.writeFileSync 覆盖写入整个 JSON 数组,确保 JSON 格式的正确性
fs.writeFileSync(logFilePath, JSON.stringify(logs, null, 2), 'utf8');
console.log(`[MCP Logger] 日志写入成功。`);
} catch (err) {
console.error(`[MCP Logger] 写入日志文件失败:`, err);
}
}

// --- 捕获进程退出事件,确保日志写入 ---
process.on('exit', writeLogsToFile);
process.on('SIGINT', () => { // Ctrl+C 信号
console.log('\n[MCP Logger] 捕获到 SIGINT 信号,正在退出...');
process.exit(0);
});
process.on('SIGTERM', () => { // 终止信号
console.log('\n[MCP Logger] 捕获到 SIGTERM 信号,正在退出...');
process.exit(0);
});
process.on('uncaughtException', (err) => { // 未捕获的异常
console.error('\n[MCP Logger] 捕获到未处理的异常,正在退出:', err);
writeLogsToFile(); // 尝试在异常退出前写入日志
process.exit(1);
});

// --- 在启动时加载现有日志 ---
loadExistingLogs(); // 这一步至关重要,它会在新的日志被添加前,先加载之前的历史日志

console.log(`[MCP Logger] 启动代理('${serverName}'),代理脚本: ${targetScriptPath}`);
console.log(`[MCP Logger] 日志文件路径: ${logFilePath}`);

// --- 启动目标脚本作为子进程 ---
// 重要的:确保 'node' 命令的绝对路径是正确的,例如 '/usr/local/bin/node'
// 如果之前您已经配置了绝对路径,请保持不变。否则,请更换为您的 'node' 绝对路径。
const child = spawn('node', [targetScriptPath], { stdio: 'pipe' });

// --- 代理 stdin (cline -> logger -> child) ---
process.stdin.on('data', (data) => {
logInteraction('input_from_cline', data, 'cline');
child.stdin.write(data);
});
process.stdin.on('end', () => {
child.stdin.end();
console.log('[MCP Logger] cline 标准输入流已关闭。');
});

// --- 代理 stdout (child -> logger -> cline) ---
child.stdout.on('data', (data) => {
logInteraction('output_to_cline', data, 'child_stdout');
process.stdout.write(data); // 将子进程输出转发回 cline
});
child.stdout.on('end', () => {
console.log('[MCP Logger] 子进程标准输出流已关闭。');
});

// --- 捕获子进程 stderr (child -> logger) ---
child.stderr.on('data', (data) => {
logInteraction('child_stderr', data, 'child_stderr');
process.stderr.write(data); // 将子进程的错误也转发到 cline 的 stderr,方便调试
});
child.stderr.on('end', () => {
console.log('[MCP Logger] 子进程标准错误流已关闭。');
});

// --- 监听子进程退出事件 ---
child.on('close', (code) => {
console.log(`[MCP Logger] 子进程 '${targetScriptPath}' 已退出,退出码: ${code}`);
// 如果子进程异常退出,我们也可以选择让主进程也退出
if (code !== 0) {
process.exit(code);
}
});

child.on('error', (err) => {
console.error(`[MCP Logger] 启动子进程 '${targetScriptPath}' 失败:`, err);
process.exit(1);
});

// 确保主进程的 stdin 不会自动关闭,以便保持监听 cline 的输入
process.stdin.resume();


别忘了修改 MCP server 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"mcpServers": {
"weather": {
"disabled": false,
"timeout": 60,
"type": "stdio",
"command": "node",
"args": [
"/Users/zhangsan/Downloads/Code/cline/weather/mcp-stdio-logger.cjs",
"/Users/zhangsan/Downloads/Code/cline/weather/build/index.js",
"weather"
]
}
}
}

执行天气查询

建立新的会话,询问:洛杉矶的天气情况?
任务执行完毕后,获得日志文件 mcp_interaction_log_weather.json

解析

输入:Cline --> MCP server
输出:MCP server --> Cline
logs

上图涵盖了 Cline 与MCP server之间,互相 “打招呼” ,确定对应版本号等
以上日志发生在注册工具的一瞬间!

接下来,解析查询天气日志信息:

action

日志输出了调用 get_forecast 方法,参数为目标地址的经纬度,然后得到 11
即最终的天气情况。

日志 10 调用 get_forecast 方法的入参遵循 日志 5 中的 get_forecast 规范 rule.png

直接与MCP server 进行交互

为了进一步理解日志内容,接下来我们不用 Cline ,直接通过命令行来与 MCP server 进行交互

  1. 在 weather 文件夹下启动命令行 执行 node ./build/index.js
  2. 命令行出现 Weather MCP Server running on stdio 即 MCP Server 已启动
  3. 复制日志中的第一行内容,把 cline 改成任意名称,比如: zhangsan
  4. 逐行复制日志中的输入项,会看到输出会按照日志的内容返回

到这里你应该已经理解 MCP 协议了,你甚至可以不使用 MCP 库也能够开发MCP server 了

你的 MCP server 只要遵循 MCP的这个规范即可!(日志中所示)

MCP 含义与地位

通过实战我们其实可以简单粗暴的理解 MCP 协议,即:函数的注册与调用
mcp.png

MCP协议就是规定了如何发现调用函数的,这套协议与大模型没什么关系。因为它并没有规定与大模型的交互方式!

这里引出了大模型的交互协议,目前主流的交互协议有: XMLFunction Calling等。他们规定了模型是如何调用函数的。

总结

MCP协议的本质:MCP协议规定了如何发现调用函数的并未规定如何与模型进行交互

model
服务模型

个人觉得 MCP 名字有点用错了,其实叫 Function-Call-Protocol 比较贴切

文章部分内容来自 马克的工作坊