MCP Server 开发教程:手写你的第一个 MCP 服务器(2026)
装几个现成的 MCP 服务器,让 Claude Code 能查 GitHub、读 Notion,这是大多数人对 MCP 的全部体感。但真正拉开效率差距的是下一步:把你们公司的工单系统、内部数据库、审批接口,用一百来行代码封装成一个 MCP Server,让 AI 直接调用。这篇 MCP Server 开发教程用 Python 和 TypeScript 两条路线,带你从空目录写到一个能被 Claude Code 真实调用的服务器,并讲清传输方式怎么选、怎么调试、以及哪些安全红线不能碰。
什么时候值得自己写一个 MCP Server
先劝退一半人:如果你的需求是「让 AI 读本地文件」「跑 shell 命令」「查 GitHub issue」,社区已经有成熟服务器,装现成的即可,别重复造轮子。自己动手写,只在下面这几种情况下真正划算。
- 系统是内网私有的——公司自研的工单、CRM、审批流,外面不可能有现成 MCP 服务器。
- 调用链条很固定——比如「查订单号 → 拉物流 → 生成话术」,与其每次让 AI 现拼三个 API,不如封装成一个语义清晰的工具。
- 要给 AI 加护栏——直接给 AI 一个数据库连接太危险,包一层 MCP,只暴露几个只读查询,权限自己说了算。
- 要在团队里复用——写成 MCP Server 提交进仓库,全组的 Claude Code / Cursor 都能用同一套工具,不用每人配一遍。
MCP Server 到底提供什么
一个 MCP 服务器可以提供三类能力:Tools(AI 可调用的函数,需用户批准)、Resources(可被读取的类文件数据,如 API 返回、文件内容)、Prompts(预写的提示词模板)。日常开发九成场景只需要 Tools,本文也以 Tools 为主。
选 Python 还是 TypeScript
官方对两种语言的 SDK 支持度基本一致(另外还有 Java、Kotlin、C# 等)。选哪个主要看你要对接的系统本身用什么写的——毕竟 MCP 服务器的绝大部分代码是在调你自己的业务逻辑。
| 维度 | Python | TypeScript / Node |
|---|---|---|
| 上手速度 | 更快,装饰器 + 类型注解自动生成工具定义 | 略慢,需要写 zod schema |
| 代码量 | 少,一个 @mcp.tool() 搞定 | 多一点,registerTool 需要显式描述 |
| 参数校验 | 靠类型注解和 docstring | 靠 zod,校验能力更强、报错更清晰 |
| 分发方式 | uv / pip,用户需要本地 Python 环境 | npm 发包后可 npx 直接跑,分发最省事 |
| 适合场景 | 数据、算法、内部脚本类系统 | Web 后端、要发给别人一键使用的服务器 |
一句话建议:只给自己和同事用、后端是 Python 的,选 Python;要发布出去让别人 npx 一行装上的,选 TypeScript。
Python 路线:写一个能查工单的 MCP Server
官方推荐用 uv 管理项目。先建目录、装依赖:
# 新建项目
uv init ticket-server
cd ticket-server
# 创建并激活虚拟环境
uv venv
source .venv/bin/activate # Windows 用 .venv\Scripts\activate
# 安装 MCP SDK
uv add "mcp[cli]"
# 创建服务器文件
touch server.py然后是服务器本体。核心只有三件事:创建实例、用装饰器注册工具、以 stdio 方式跑起来。注意工具的类型注解和 docstring 不是写给人看的——SDK 会拿它们自动生成工具定义(名称、参数、描述)交给模型,所以 docstring 写得越准,AI 调得越对。
from mcp.server import MCPServer
# 初始化服务器实例,名字会显示在客户端里
mcp = MCPServer("ticket")
# 这里用假数据演示,实际换成你的数据库查询 / 内部 API 调用
TICKETS = {
"T-1001": {"title": "登录页验证码不显示", "status": "处理中", "owner": "张伟"},
"T-1002": {"title": "导出 Excel 乱码", "status": "已关闭", "owner": "李娜"},
}
@mcp.tool()
async def get_ticket(ticket_id: str) -> str:
"""根据工单号查询工单详情。
Args:
ticket_id: 工单编号,形如 T-1001
"""
ticket = TICKETS.get(ticket_id.upper())
if not ticket:
return f"没有找到工单 {ticket_id}"
return (
f"工单号:{ticket_id}\n"
f"标题:{ticket['title']}\n"
f"状态:{ticket['status']}\n"
f"负责人:{ticket['owner']}"
)
@mcp.tool()
async def list_open_tickets() -> str:
"""列出所有未关闭的工单。"""
rows = [
f"{tid}: {t['title']}({t['owner']})"
for tid, t in TICKETS.items()
if t["status"] != "已关闭"
]
return "\n".join(rows) if rows else "当前没有未关闭工单"
if __name__ == "__main__":
mcp.run(transport="stdio")类名改过,网上老教程会报错
早期 Python SDK 里这个类叫 FastMCP(写法是 from mcp.server.fastmcp import FastMCP),官方最新文档已经改成 MCPServer。你在 CSDN、掘金上搜到的 2025 年教程基本还是 FastMCP 写法。如果 import 报错,先 uv pip show mcp 看一眼装的版本,再对照该版本的官方文档,别硬套博客里的写法。
写完直接 uv run server.py 就能启动。注意它启动后不会打印什么、也不会退出——这是对的,stdio 服务器就是在等客户端从标准输入喂消息进来。真正的验证要靠下面的 Inspector 或接进客户端。
TypeScript 路线:同一个服务器的 Node 版
Node 需要 20 或更高版本。建项目并装依赖:
mkdir ticket-server && cd ticket-server
npm init -y
# 官方包名在 2026 年改过,注意是 /server 不是老的 /sdk
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src && touch src/index.tspackage.json 里要加上 "type": "module" 和构建脚本,否则 ESM 导入会失败:
{
"type": "module",
"bin": {
"ticket-server": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": ["build"]
}服务器本体。和 Python 版的区别在于参数 schema 要用 zod 显式声明,返回值必须包成 content 数组:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const server = new McpServer({
name: "ticket",
version: "1.0.0",
});
const TICKETS: Record<string, { title: string; status: string; owner: string }> = {
"T-1001": { title: "登录页验证码不显示", status: "处理中", owner: "张伟" },
"T-1002": { title: "导出 Excel 乱码", status: "已关闭", owner: "李娜" },
};
server.registerTool(
"get_ticket",
{
description: "根据工单号查询工单详情",
inputSchema: z.object({
ticket_id: z.string().describe("工单编号,形如 T-1001"),
}),
},
async ({ ticket_id }) => {
const ticket = TICKETS[ticket_id.toUpperCase()];
if (!ticket) {
return { content: [{ type: "text", text: `没有找到工单 ${ticket_id}` }] };
}
return {
content: [
{
type: "text",
text: `工单号:${ticket_id}\n标题:${ticket.title}\n状态:${ticket.status}\n负责人:${ticket.owner}`,
},
],
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// 注意:日志必须走 stderr,写 stdout 会污染 JSON-RPC 通道
console.error("Ticket MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});最高频的一个坑:别往 stdout 打日志
stdio 模式下,标准输出是 JSON-RPC 协议通道。你随手写一句 console.log("debug") 或 print("..."),就会把协议消息搅乱,客户端表现为「服务器连不上」或「工具列表是空的」,而且报错信息毫无提示性。所有调试输出一律走 stderr(TypeScript 用 console.error,Python 用 logging 输出到 stderr)。
写完必须跑一次 npm run build,客户端启动的是编译后的 build/index.js,不是 .ts 源文件——漏了这步是新手第二高频的失败原因。
stdio 还是 Streamable HTTP:传输方式怎么选
MCP 有两种主流传输方式。简单说:只给自己本机用就 stdio,要给一群人远程访问就 Streamable HTTP。
| 对比项 | stdio | Streamable HTTP |
|---|---|---|
| 运行位置 | 客户端在本机把它当子进程拉起来 | 独立部署的远程服务,客户端连 URL |
| 延迟 | 极低(本机进程间通信) | 取决于网络,跨公网明显更高 |
| 鉴权 | 不需要,靠本机权限 | 必须自己做(OAuth / Header Token) |
| 分发 | 每个人本地装一遍环境 | 部署一次全员可用,升级只改服务端 |
| 适合 | 个人工具、读本地文件、连本机数据库 | 团队共享、内部平台、SaaS 化的能力 |
补充一点历史包袱:更早的规范里还有一种 HTTP + SSE 传输,现已被 Streamable HTTP 取代,只为向后兼容保留。新写的服务器不要再选 SSE。另外在客户端配置里,type 字段的 streamable-http 和 http 是同一个东西的两种写法,从服务器文档里复制过来的配置一般不用改。
接进 Claude Code、Claude Desktop 和 Cursor
Claude Code 用命令行加最省事。stdio 服务器的关键是那个双横线:它左边是 Claude 自己的选项,右边是「怎么把你的服务器跑起来」的完整命令,会被原样传过去。
# 加一个本地 stdio 服务器(Python 版)
claude mcp add --transport stdio ticket -- uv --directory /绝对路径/ticket-server run server.py
# TypeScript 版(注意指向编译后的 build/index.js)
claude mcp add --transport stdio ticket -- node /绝对路径/ticket-server/build/index.js
# 需要传密钥时用 --env
claude mcp add --env TICKET_API_KEY=你的密钥 --transport stdio ticket -- node /绝对路径/build/index.js
# 远程 HTTP 服务器
claude mcp add --transport http ticket https://mcp.your-company.com/mcp
# 查看状态:会显示 ✔ Connected / ✘ Failed to connect
claude mcp list
claude mcp get ticket配置存在哪由 --scope 决定:local 只对当前项目的你自己生效(默认);project 写进项目根目录的 .mcp.json,可以提交到 Git 让全组共用;user 则对你所有项目生效。团队协作场景基本都用 project。
# 写进 .mcp.json,提交到仓库全组共享
claude mcp add --transport stdio ticket --scope project -- node ./build/index.jsClaude Desktop 则是手工改配置文件:macOS 在 ~/Library/Application Support/Claude/claude_desktop_config.json,Windows 在 %AppData%\Claude\claude_desktop_config.json(文件不存在就自己建)。Cursor 用的是同一套 JSON 结构,放在项目的 .cursor/mcp.json 或全局 ~/.cursor/mcp.json 里。
{
"mcpServers": {
"ticket": {
"command": "node",
"args": ["/绝对路径/ticket-server/build/index.js"],
"env": {
"TICKET_API_KEY": "你的密钥"
}
}
}
}路径一定写绝对路径
客户端拉起子进程时的工作目录不一定是你的项目目录,写相对路径几乎必然「连不上」。同理,命令名也建议写全路径(比如 which node 查出来的那个),因为客户端继承的 PATH 可能和你终端里的不一样——这是 nvm / conda 用户最容易中的招。
调试:先过 Inspector 再接客户端
别一写完就往 Claude Code 里塞。客户端连不上时给的报错极其含糊,而官方的 MCP Inspector 是一个网页版调试台,能列出你的工具、直接手动调用、看原始的请求响应报文,定位问题快十倍。
# 调试 stdio 服务器:把启动命令跟在后面
npx @modelcontextprotocol/inspector node build/index.js
npx @modelcontextprotocol/inspector uv --directory . run server.py
# 调试已经跑起来的 HTTP 服务器
npx @modelcontextprotocol/inspector --url http://localhost:3000调试顺序建议是:Inspector 里工具能正常列出来、手动调用返回符合预期 → 再接进 Claude Code → 最后才优化工具描述。如果 Inspector 里都是好的、接客户端却不行,那问题基本在配置(路径、PATH、没 build),不在代码。
安全红线:MCP 是实打实的攻击面
这一节请务必读完。MCP 生态在 2026 年前四个月就公开了三十多个 CVE,其中包括 mcp-remote 里 CVSS 9.6 的远程代码执行漏洞;MCP Inspector 自身也有过一个严重 RCE(CVE-2025-49596),恶意服务器能在跑 Inspector 的机器上执行任意代码。写服务器时至少守住下面几条。
- 所有工具入参当成不可信输入处理——AI 传进来的参数可能是被提示词注入操纵的结果,该做的 SQL 参数化、路径穿越校验、命令注入过滤一个都不能省。
- 本地 HTTP 服务器绑 127.0.0.1,不要绑 0.0.0.0,否则同网段的人可以直接调用你的工具。
- 校验每个请求的 Origin 头,防止网页通过浏览器发起跨站请求打你的本地服务器。
- 工具粒度按最小权限设计——只读场景就别提供 delete_ticket,权限收在服务器这一层最可靠。
- 密钥走环境变量,绝不硬编码进代码,更不要提交进 .mcp.json 后推到公开仓库。
- 装第三方 MCP 服务器前先看源码和 star 数——它拿到的是你本机的执行权限,等同于装一个后台程序。
- MCP Inspector 保持在较新版本(≥ 0.14.1),老版本有已知 RCE。
让 AI 真正会用你的工具:描述比代码重要
服务器跑通只是及格线。实际用起来最常见的抱怨是「AI 就是不调我的工具」或者「参数老传错」,九成情况不是代码问题,是工具描述写得太随意。几个立竿见影的做法:
- 1工具名用动词短语,语义唯一:get_ticket 好过 query,search_order_by_phone 好过 search2。
- 2描述里写清「什么时候该用它」,而不只是「它是什么」。比如「当用户提到工单号(形如 T-1001)时用本工具查询详情」。
- 3每个参数都加 describe/docstring 说明格式和示例值,模型极依赖这个来构造入参。
- 4返回值给结构清晰的纯文本,别直接甩原始 JSON——模型读格式化后的文本更准,也更省 token。
- 5工具数量控制在个位数。一个服务器塞三十个工具,模型选不准,还白白吃掉上下文窗口。
把内部系统封装成 MCP Server,本质上是在给 AI 铺路:AI 能触达的系统越多、工具语义越清晰,它在你项目里能独立完成的事就越多。这也是从「让 AI 写几段代码」升级到「让 AI 参与真实工程」的分水岭。如果你想系统掌握 MCP、上下文工程、Claude Code 工作流这一整套用 AI 做出可交付产品的方法,欢迎来 IMAI 看看我们的体系化实战课程。
想系统学会用 AI 编程,从入门到做出真实产品?
查看系统课程