前言

去年写 Function Calling 和 Agent UI 那几篇文章的时候,我反复遇到同一个困扰:让大模型”接上”真实世界的数据,每个应用、每个数据源都得单独写一遍胶水代码。给 Claude 接 GitHub 是一套封装,给 Cursor 接同样的 GitHub 又是另一套,自己写的客服机器人想查数据库还得再来一遍。这些适配逻辑高度雷同,却完全无法复用。

今年年初我认真研究了 Anthropic 开源的 Model Context Protocol(下文简称 MCP),越看越觉得它像是 AI 应用生态里的”USB-C 接口”。这篇文章从前端视角聊聊:MCP 到底解决什么问题、架构长什么样,以及如何动手写一个最小可用的 MCP Server,最后附上我踩过的坑。

1. MCP 是什么:从 M×N 到 M+N

1.1 M×N 集成难题

在 MCP 出现之前,AI 应用接入外部能力是一个典型的 M×N 问题:M 个 AI 应用(Claude Desktop、Cursor、各种自研 Agent)要对接 N 个数据源(GitHub、Slack、Postgres、本地文件……),理论上最多要写 M×N 份适配代码。更糟的是这些代码彼此不通用:每家的调用约定、鉴权方式、数据格式都不一样。

用文字画个示意图对比一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
接入前(M×N):每个应用 × 每个数据源,都要单独写适配

Claude ──── 适配器A ───> GitHub
Claude ──── 适配器B ───> Slack
Cursor ──── 适配器C ───> GitHub
Cursor ──── 适配器D ───> Slack
自研Agent ── 适配器E ───> 数据库 // M×N 份重复劳动

接入后(M+N):大家只面向同一个协议

Claude ───┐ ┌─── GitHub Server
Cursor ───┼──── MCP 协议 ────────┼─── Slack Server
自研Agent ─┘ └─── 自建业务 Server

MCP 的思路很简单:把”应用侧”和”数据源侧”解耦。数据源方只要实现一次 MCP Server,所有支持 MCP 的应用都能直接用;应用方只要实现一次 MCP Client,就能接入整个生态。M×N 的集成难题就退化成了 M+N。

1.2 一层薄薄的协议

MCP 本质上是一份基于 JSON-RPC 2.0 的协议规范,官方提供了 TypeScript 和 Python 的 SDK,传输层支持 stdio(本地子进程)和 HTTP + SSE(远程服务)。对前端来说,它的心智模型接近”给 AI 用的 RPC”:客户端发 tools/list 问你有什么能力,发 tools/call 调用某个工具,你按协议回 JSON 就行。

一次完整的会话大致是这样的:Host 启动时通过 initialize 握手交换协议版本和能力声明;接着 Client 发 tools/list 拉取工具清单(名称、描述、入参 schema),把这份清单注入模型的系统提示;模型决定调用某个工具后,Host 再发 tools/call,由 Server 执行并把结果作为一条工具消息回给模型。整个过程对模型完全透明——模型看到的只是”有哪些工具可用”,它并不知道背后是 MCP。

2. 架构一览:Host、Client 与 Server

MCP 的三层架构

MCP 的角色划分有点绕,第一次看很容易混淆,我用自己的话解释一遍:

  • Host:承载 AI 对话的宿主应用,比如 Claude Desktop、Cursor,或者你自己的聊天界面。它是”用户”。
  • Client:跑在 Host 内部的连接器,每个 Client 与一个 Server 保持 1:1 的会话,负责协议握手与消息路由。
  • Server:对外暴露能力的轻量服务,可以是一个本地子进程,也可以是一个远程 HTTP 服务。

Server 对外提供三类原语,MCP 的核心基本就是这三样:

  • Resources:只读数据,比如文件内容、数据库记录,类似 GET 接口,模型可以”读取”;
  • Tools:可执行的动作,有副作用,类似 POST 接口,也就是 Function Calling 的 MCP 版;
  • Prompts:预置的提示词模板,让 Server 作者把最佳实践打包分发给用户。

3. 动手写一个最小 MCP Server

3.1 初始化项目

用官方 TypeScript SDK,五分钟就能跑起来:

1
2
3
# 直接在博客仓库根目录下操作,把 Server 当作仓库里的一个脚本
mkdir scripts
npm i @modelcontextprotocol/sdk zod tsx

3.2 实现”查询博客文章列表”工具

我的博客是 Hexo 驱动的,文章就是 source/_posts 下的一堆 Markdown。下面这个 Server 暴露两个工具:列出文章列表、按关键词搜索文章正文。

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
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import * as path from "node:path";

const POSTS_DIR = path.resolve(process.cwd(), "source/_posts");

// 极简 front-matter 提取,实际项目建议换成 gray-matter
function parseTitle(raw: string): string {
const m = raw.match(/^---\n[\s\S]*?title:\s*(.+)\n[\s\S]*?---/);
return m ? m[1].trim() : "未命名";
}

async function listPosts(limit = 10) {
const files = (await fs.readdir(POSTS_DIR)).filter((f) => f.endsWith(".md"));
const posts = await Promise.all(
files.map(async (f) => {
const content = await fs.readFile(path.join(POSTS_DIR, f), "utf-8");
return { file: f, title: parseTitle(content) };
})
);
return posts.slice(0, limit);
}

async function searchPosts(keyword: string, limit = 5) {
const files = (await fs.readdir(POSTS_DIR)).filter((f) => f.endsWith(".md"));
const hits: { file: string; title: string; snippet: string }[] = [];
for (const f of files) {
const content = await fs.readFile(path.join(POSTS_DIR, f), "utf-8");
const idx = content.indexOf(keyword);
if (idx !== -1) {
hits.push({
file: f,
title: parseTitle(content),
snippet: content.slice(Math.max(0, idx - 40), idx + 80),
});
}
if (hits.length >= limit) break;
}
return hits;
}

const server = new McpServer({ name: "hexo-blog", version: "0.1.0" });

server.tool(
"list_posts",
"列出博客文章列表(标题 + 文件名)",
{ limit: z.number().int().min(1).max(50).default(10).describe("返回条数") },
async ({ limit }) => ({
content: [{ type: "text", text: JSON.stringify(await listPosts(limit), null, 2) }],
})
);

server.tool(
"search_posts",
"按关键词搜索文章正文,返回命中片段",
{
keyword: z.string().describe("搜索关键词"),
limit: z.number().int().min(1).max(20).default(5),
},
async ({ keyword, limit }) => ({
content: [{ type: "text", text: JSON.stringify(await searchPosts(keyword, limit), null, 2) }],
})
);

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}

main().catch((err) => {
console.error(err); // 注意:错误只能打到 stderr
process.exit(1);
});

几个容易忽略的点:

  • server.tool() 的第三个参数用 zod 定义入参 schema,SDK 会自动生成符合协议的 inputSchema,模型就是靠它知道该怎么传参;
  • 工具返回值统一包在 content 数组里,文本用 type: "text";如果要返回截图之类的图片,可以用 type: "image" 传 base64;
  • 把上面的代码存成博客仓库里的 scripts/mcp-server.ts,运行方式是在博客根目录执行 npx tsx scripts/mcp-server.ts,这样 process.cwd() 才指向博客目录,工具才能找到 source/_posts。

3.3 在 Claude Desktop 里调试

打开 Claude Desktop 的配置文件(macOS 在 ~/Library/Application Support/Claude/claude_desktop_config.json,Windows 在 %APPDATA%\Claude\claude_desktop_config.json):

1
2
3
4
5
6
7
8
{
"mcpServers": {
"hexo-blog": {
"command": "npx",
"args": ["tsx", "E:\\code\\self\\hexo-blog\\scripts\\mcp-server.ts"]
}
}
}

重启 Claude Desktop,输入框右下角出现工具图标就说明握手成功了。这时问它”我博客里写过哪些跟 AI 相关的文章?”,它会自动调用 list_posts,再视情况用 search_posts 检索关键词,全程不需要写任何一行 Claude 特定的代码,这就是统一协议的价值。

4. 前端视角:MCP 与 Function Calling 的关系

很多人第一反应是:这不就是 Function Calling 换了个皮?我的理解是两者工作在不同层次:

  • Function Calling 是模型层能力,各家私有:OpenAI、Anthropic、DeepSeek 各有一套 schema 格式,之前 DeepSeek 接入那篇文章里我就专门封装过这层。它解决的是”模型如何决定调用哪个函数”。
  • MCP 是应用层开放协议:它不关心底下模型用什么方式做函数调用,只规定”能力如何被描述、被发现、被调用”。同一个 MCP Server 今天接 Claude,明天接 Cursor,后天接自己写的 Agent,都不用改。

可以这么类比:Function Calling 是各家 CPU 的指令集,MCP 是 USB 标准。未来大概率是模型继续各自演进指令集,而外设生态统一到 MCP 这类协议上。

实际落地时两者是配合关系:Host 先从 MCP Server 拿到工具清单,转换成当前模型厂商的 function calling 格式喂给模型;模型吐出调用意图后,Host 再翻译回 MCP 的 tools/call 发给 Server。换句话说,MCP 负责”能力的定义与发现”,Function Calling 负责”模型的决策与触发”,谁也替代不了谁。

5. 踩坑记录

5.1 stdio 传输下千万别往 stdout 打日志

这是新手必踩的坑。stdio 模式下 stdout 是协议消息专用通道,任何 console.log 都会输出到 stdout,直接把 JSON-RPC 消息流污染,客户端立刻报”invalid JSON”。调试信息请一律走 console.error(stderr)。

5.2 Windows 路径与命令解析

在 Windows 上配置 Claude Desktop 时有两个坑:一是 JSON 里的路径反斜杠要转义,写 E:\\code\\... 而不是 E:\code\...;二是 command 写 npx 在某些机器上解析不到,需要写成 npx.cmd,或者干脆用 node 直接跑编译后的 JS。另外 SDK 的子路径导入(@modelcontextprotocol/sdk/server/mcp.js)要求 package.json 里声明 "type": "module",否则运行时会报模块加载错误。

5.3 权限与安全

MCP Server 跑在你本机,拥有和你的用户进程同等的文件系统权限。Claude Desktop 每次调用工具前会弹出授权确认,但自己写的 Host 就要自己做这一层了。我的建议:只读工具要在描述里说清楚无副作用;任何带写操作的工具都要二次确认;不要把 API Key 之类敏感信息通过工具返回给模型。

6. 生态展望

写这篇文章的时候,MCP 的生态还在爆发前夜:官方开源了一批 Reference Server(GitHub、Slack、Postgres 等),社区里文件系统、浏览器自动化、数据库方向的 Server 已经很活跃。对前端工程师来说,这里有明显的角色空缺:写 MCP Server 的体验很像写 BFF/中间层,定义 schema、处理路由、返回结构化数据,全是前端的舒适区。我甚至觉得每个团队都值得写几个内部 MCP Server:把内部文档库、工单系统、发布平台包一层,团队成员的 Claude 和 Cursor 就直接”认识”了这些系统。相比每人写一遍浏览器插件或者临时脚本,这是一次投入、全员受益的公共基建,太适合前端来牵头了。

写在最后

从去年写 Function Calling 到今天写 MCP,我的体感是:AI 应用的”集成层”正在快速标准化,就像十年前大家从各自为政的 HTTP 调用走向 Swagger/OpenAPI 一样。前端正好卡在”协议、界面、工具”三件事的交汇处,这活儿躲不开,早点趟一遍不亏。下一篇我打算聊聊多模态,把 Vision API 和 Web Audio 玩起来。