1. 真正卡住你的不是模型,而是"工具接入方式"
我相信很多人和我一样的经历:第一次跑通大模型的 Function Calling 时,会觉得自己已经掌握了 AI 应用开发的终极秘密。结果往深处做才发现,模型只是会"说话"而已,真正麻烦的是如何把你自己的业务系统、数据库、内部接口,安全又高效地接到模型面前。
这时候"MCP Server"就成了绕不开的关键词。MCP 全称 Model Context Protocol,本质上是一套让 AI 应用连接外部工具和数据的公开协议。你可以在里面暴露工具函数、文件资源、提示词模板,让各种支持 MCP 的客户端直接调用。说得直白一点,过去你想让模型帮你查订单、改配置、写数据库,需要给每个模型单独写适配层;有了 MCP Server,你只需要写一次服务,所有支持这个协议的客户端都能用。
这篇文章我会用最容易理解的方式,把协议原理拆开讲清楚,再带你把一个完整的任务管理 MCP Server 从零写到能运行,最后捎带解决很多人问的"Chrome MCP Server 怎么装、怎么用"这类实际问题。看完之后,你应该不会再对着官方文档发懵。
1.1 先打一个比方:MCP 不是一套框架,而是"外设接口"
你可以把支持工具调用的大模型想象成一台只有系统、没有外设的电脑。它的计算和推理能力很强,但鼠标、键盘、屏幕、网线全都不存在。早期的做法是每个软件厂商自己接一套专用线:苹果用 Lightning、安卓用 Micro-USB、你家里老相机还要用专用的串口线。这就是各家 Function Calling 各自为战的年代。
MCP 干的事,就是把这堆乱七八糟的线统一成一根 USB-C。厂商只要在设备上做一个统一的 USB-C 口(MCP Server),系统就能即插即用。对开发者来说,服务端只要实现一套 MCP 协议,不用关心前端到底是 Claude 桌面端、IDE 插件还是自己写的 Agent 程序。
所以 MCP Server 不等于又一个微服务框架,它更像一个"协议适配器"。你内部的服务逻辑可以随便写,真正关键的是最外层那层 MCP 接口做得够不够标准、描述得够不够清晰。
1.2 一次接入卡住的背后,通常不是模型不行
我见过很多团队在接入外部工具时,第一版代码都是从"如何把业务函数暴露成 JSON Schema"开始的。刚开始还好,但是一旦遇到下面几种情况,基本就要返工:
- 换一个模型厂商,原来那套 function calling 结构就不兼容,要重写参数映射。
- 工具数量从两三个涨到几十个,模型经常选错工具,或不知道某个工具要不要调用。
- 你不仅要让模型调用函数,还想让它读取某个文档或模板,普通 Function Calling 没有统一的资源模型。
- 工具列表需要动态下发,有些 SaaS 产品希望让第三方开发者来贡献工具,没标准就根本没法做生态。
这些问题的共同点,是"接入协议"被捆绑在了单家模型厂商的实现里。MCP 把工具发现、参数传递、结果返回、资源读取、提示词管理都标准化了,你写的服务天然就和厂商解耦。哪怕未来底层模型又换了一家,MCP Server 几乎不用动。
1.3 什么时候该自己搭一个 MCP Server,什么时候别折腾
先给你泼盆冷水:如果你的目标只是给聊天助手加一个"查天气"的小功能,真的不需要自己从零写 MCP Server,社区里现成的官方 Server 已经覆盖了文件系统、数据库、GitHub、Slack 等常用场景。
但如果你属于下面几类情况,自己动手写一个非常值得:
- 你的业务系统有独特的数据结构,现成 Server 无法覆盖,比如内部项目管理、库存查询、工单流转。
- 你希望把"读某个数据源"和"执行某个动作"做成一套标准能力,给多个 AI 客户端共用。
- 你在做 Agent 或 SaaS 产品,需要让第三方以 MCP 形式接入扩展能力。
- 你就是想搞懂协议底层原理,方便之后给现有工具包一层 MCP 外壳。
我的建议很直接:第一遍练手就用一个能落在本地文件上的小工具,别一上来就接生产数据库。等把协议跑通、明白消息怎么流动之后,替换成真实业务是水到渠成的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 协议原理不玄乎:一条消息怎么从模型走到你的函数
很多教程一上来就让你写代码,却不讲协议里消息到底怎么走,导致后期遇到莫名其妙的报错完全没法排查。我建议大家先把下面几个概念装进脑子里,再看代码会顺畅非常多。
2.1 所有 MCP 消息都穿着一件 JSON-RPC 外套
MCP 不是一个凭空发明的私有协议,它的消息格式基于 JSON-RPC 2.0。也就是说,客户端和服务端之间传输的每一句话,本质上都是一段带方法和参数的 JSON,要么是请求,要么是响应,要么是通知。
一次最典型的工具调用过程,从外部看就是两段 JSON:
json复制{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"create_task","arguments":{"title":"写发布公告"}}}
服务端跑完你的业务函数后,返回结果也是标准 JSON:
json复制{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"{\"id\":\"...\",\"title\":\"写发布公告\"}"}]}}
理解了这一点,你就知道 MCP 并没有发明多玄乎的魔法。它真正的价值,是在 JSON-RPC 之上定义了一套"方法名和参数格式的字典"。协议规定了你该用哪个 method 去列举工具,用哪个 method 去调用工具,出错时 error code 应该如何组织。你照着这套字典实现,就能保证跨厂商兼容。
2.2 接上线的第一句话:initialize 能力协商
很多初学者第一次看抓包日志时,会发现客户端连上服务端后,发来的第一个方法不是 tools/list,而是 initialize。
这是协议里非常重要的一步握手。客户端会先告诉服务端:我支持的协议版本是什么、我具备什么能力、我是谁。服务端收到后,也需要回一段包含自己名称、版本、能力列表的 JSON。响应之后,客户端还要再发一个 notifications/initialized 通知,双方才会进入正式工作状态。
在这段握手过程中,最关键的信息是能力协商。客户端可以在 capabilities 里宣称自己支持 roots 或 sampling,服务端也可以在 capabilities 里宣称自己支持 tools、resources、prompts。只有当两边都认可对应能力时,后续那类方法才可以调用。否则即使你偷偷发过去,客户端也可以直接忽略。
| 能力 | 谁声明 | 作用 |
|---|---|---|
| tools | 服务端 | 声明自己提供可被模型调用的函数 |
| resources | 服务端 | 声明自己提供可读取的资源内容 |
| prompts | 服务端 | 声明自己提供可复用的提示词模板 |
| roots | 客户端 | 声明自己能给服务端暴露文件系统根目录 |
| sampling | 客户端 | 声明模型可以反向请求模型生成内容 |
平时你不需要手动构造这些 JSON,官方 SDK 在 McpServer 初始化时会自动完成大部分逻辑。但一旦排查连线问题,你至少要知道这个顺序对不对、两端能力有没有匹配。
2.3 Tools、Resources、Prompts——三种原语的分工
MCP 的核心不是只有"工具",而是三种原语。刚开始我不理解为什么要拆三种,后来在真实场景里才体会到:它们服务的其实是不同类型的人机协作需求。
**Tools(工具)**是最像传统 Function Calling 的概念。它由模型在推理过程中主动决定是否调用,可以产生副作用,比如建任务、更新状态、发消息。它回答的是"让模型能做什么"。
**Resources(资源)**更像是一份"可被读取的资料"。它通过 URI 暴露,比如 tasks://summary、file:///etc/config。模型不会主动调用它去执行操作,更多是当对话需要上下文时,客户端把特定 URI 对应的内容取出来塞给模型。它回答的是"让模型能读到什么"。
**Prompts(提示词模板)**则是一套预置的"话术库"。它不是被模型运行时调用,而是用户在客户端里主动选择某个模板,然后把模板内容注入会话。它回答的是"如何让模型更好地开始一段任务"。
在大多数实战项目中,Tools 用得非常频繁,Resources 和 Prompts 则能锦上添花。比如一个支持 MCP 的任务管理 Server,既可以有 create_task 这样的工具,也能暴露 tasks://summary 这样的资源,还能提供一个"站会汇报"的 Prompt 模板。三者拼起来,体验才会完整。
三者的使用场景和调用方完全不同,我整理成了一张表供你以后设计时参考:
| 原语 | 调用方 | 典型用途 | 能否产生副作用 |
|---|---|---|---|
| Tools | 模型自主决定 | 操作业务数据、执行函数 | 可以 |
| Resources | 客户端按需读取 | 提供上下文、配置、文档 | 通常不可以 |
| Prompts | 用户主动选择 | 注入任务模板、角色设定 | 不可以 |
2.4 传输通道选 stdio 还是 HTTP,主要看你在哪跑
MCP 的消息格式和业务逻辑与传输层是解耦的。SDK 里最常见的有两种 Transport:stdio 和基于 HTTP 的 Streamable HTTP(早期是 HTTP + SSE)。
stdio 的意思是把 MCP Server 作为客户端进程的子进程跑起来,双方通过标准输入和标准输出传递 JSON 消息。这是本地开发时最方便的方式:不需要开端口、不用考虑鉴权、进程随客户端启动和退出。你只要在客户端的配置文件里写清楚要执行什么命令即可。
HTTP 传输则适合服务端部署。Server 作为一个独立服务跑在远程机器上,客户端通过 URL 发起请求。这样一套服务可以被多个使用者共享,也让跨设备调用成为可能。
选型逻辑其实很朴素:只在自己电脑上用的工具用 stdio,要部署给别人用的服务走 HTTP。后面我写代码时会分别展示,你自己动手时也能一键切换。
3. 动手前最该做的两件事:选型与工具契约设计
先别急着 npm init,我也曾经因为少想一步就写,结果写完才发现工具粒度不对、命名混乱、描述含糊,重构成本很高。预则立,这一步值得花点时间。
3.1 语言与 SDK:新项目我建议无脑优先 TypeScript
MCP 官方 SDK 主要的两个阵营是 TypeScript 和 Python。如果你不是完全只会 Python,我个人强烈建议第一个 MCP Server 用 TypeScript 写。原因不是 TS 比 Python 好在哪,而是 MCP 协议由 Anthropic 提出,官方生态里最先稳定、示例最多的一直是 TypeScript。遇到问题能搜到的资料量完全不是一个级别。
另一个重要原因是 TypeScript SDK 从 1.x 开始提供了一个特别友好的高层封装 McpServer。你用它的 tool、resource、prompt 三个方法,就能像注册路由一样快速暴露能力。参数校验用 Zod,类型安全也很舒服。
Python SDK 本身也完全可用,如果你团队后端是 Python 技术栈,倒不必强行改成 Node。但请记住,无论用哪个语言,尽量不要自己裸写 JSON-RPC,协议细节比想象中多,你会花大量时间去处理边界情况。
3.2 先设计一张"能力地图",再想怎么写代码
写代码前,花十五分钟把你希望模型能做的事列成清单。不要用"随便让模型操作任务"这种模糊目标,最好是让普通同事也能看懂的描述。
比如我们这篇文章要做的例子是一个本地任务管理 MCP Server。它的核心使用场景是:
- 用户对 AI 说:"帮我创建一个明天截止的高优先级任务:写季度复盘。"
- 用户对 AI 说:"现在有多少没完成的任务?"
- 用户对 AI 说:"生成一份站会汇报模板。"
根据场景,能力地图大致如下:
| 能力类型 | 名称 | 作用 |
|---|---|---|
| Tool | create_task | 创建任务,支持标题、优先级、截止时间 |
| Tool | list_tasks | 查询任务,可按状态过滤 |
| Tool | update_task_status | 更新任务状态,如 todo 改为 done |
| Resource | tasks://summary | 返回当前任务总量和状态分布 |
| Prompt | standup_report | 生成站会汇报模板 |
有了这张表,你后面写代码就会非常清晰。我见过很多新手跳过了这个环节,边写边想,最后 tools 的名称五花八门,参数一会儿 snake_case 一会儿 camelCase,模型都困惑。
3.3 Tool 名称和描述就是接口的注释,请当文档写
在 MCP 里,每个 Tool 的 description 字段不是摆设,模型会依据名称和描述来决定要不要调用这个工具。写得好不好,直接决定模型是"精准出手"还是"疯狂乱试"。
我踩过一个很经典的坑:给一个工具写 description 时自认为很全面,用了两百多个字,把底层实现细节都写进去了。结果模型看到长篇大论后,反而不知道该在什么时候用。后来我把描述精简成"创建一条新的待办任务,返回该任务的完整 JSON",效果立刻好了。
描述的建议规则:
- 一句话说清楚"这个工具做了什么"。
- 不要在描述里写实现细节,比如"调用数据库后返回 JSON"。
- 参数名用完整单词,避免缩写。
- 每个参数都写 describe,解释格式和约束。
- 可枚举值用 enum 限定,不要指望模型自己猜。
这些看起来都是小事,但在真实使用中,模型对工具选择准确率的影响可能高达三五成。
4. TS 代码实战:把任务管理系统包装成 MCP Server
现在进入大家最喜欢的代码环节。以下项目代码基于 @modelcontextprotocol/sdk 1.x 版本,高层封装 McpServer 的 API 在不同小版本之间可能会有细微差异,如果你装到的版本接口对不上,以官方仓库当前的 README 示例为准。
4.1 工程初始化:从零开始,不依赖远程脚手架
我们新建一个最简单的 Node + TypeScript 项目。不要被"脚手架"概念绑架,MCP Server 本质上就是一个命令行进程,老老实实从 npm init 开始,反而能让你更清楚每一层在干什么。
bash复制mkdir mcp-task-server
cd mcp-task-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript tsx @types/node
npm pkg set type=module
接着建一个 tsconfig.json:
json复制{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
然后在 package.json 的 scripts 里加上:
json复制{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
}
}
zod 这个依赖主要是给工具参数做 schema 描述用的。MCP 服务端需要把参数的 JSON Schema 发给客户端,McpServer 的高层封装能直接把 Zod 对象转换成标准 JSON Schema,省去手写一大段 JSON 的麻烦。
4.2 第一个 Tool:对模型说清楚参数有多重要
在 src 下新建 index.ts。我们先实现最外层的数据存取,用一个本地 JSON 文件当持久化存储。这里选 JSON 文件纯粹是为了演示,生产环境换成 SQLite 或真实数据库时,只需要改内部函数,MCP 协议层不用动。
ts复制import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
interface Task {
id: string;
title: string;
priority: "low" | "medium" | "high";
status: "todo" | "doing" | "done";
dueAt?: string;
createdAt: string;
}
const DB_FILE = path.join(process.cwd(), "tasks.json");
async function readTasks(): Promise<Task[]> {
try {
const raw = await fs.readFile(DB_FILE, "utf-8");
return JSON.parse(raw) as Task[];
} catch {
return [];
}
}
async function writeTasks(tasks: Task[]): Promise<void> {
await fs.writeFile(DB_FILE, JSON.stringify(tasks, null, 2), "utf-8");
}
接下来初始化 MCP Server,并注册第一个工具:
ts复制const server = new McpServer({
name: "task-assistant",
version: "
