如果 MCP 是 Agent 和外部世界之间的「USB-C 接口」,那么 MCP Server 就是那根线后面的驱动程序。前面几篇教程讲的是怎么用别人写好的 Server(把文件、数据库接给助手),这一篇反过来:自己写一个 Server,把你们内部的接口变成所有 Agent 都能调用的标准工具。全程 Python,从空目录到一个能被客户端识别的服务。
先搞懂:MCP 里的三个角色
在看代码前,先把三个名词对上号,后面就不会乱:
| 角色 | 是什么 | 例子 |
|---|---|---|
| Host(宿主) | 用户直接操作的 AI 应用 | Claude Desktop、Cursor、你自己的 Agent 平台 |
| Client(客户端) | 宿主内部负责连 Server 的组件,1 对 1 保持连接 | SDK 里帮你握手的那一层 |
| Server(服务端) | 你把能力包起来的那段轻量程序 | 本篇要写的 server.py |
一个 Server 对外可以暴露三种「原语」:Tools(可被模型调用的函数,最常用)、Resources(只读数据,由客户端决定何时塞进上下文)、Prompts(预置提示词模板)。本教程三条都会覆盖,但重心放在 Tools 上。
Step 1:准备环境
官方 Python SDK 内置了一个高层封装 FastMCP,能把普通 Python 函数直接变成工具,省掉手写 JSON Schema 的活。它需要 Python 3.10+。
# 方式 A:用 uv(推荐,官方脚手架也用这个)
mkdir my-mcp-server && cd my-mcp-server
uv init
uv add "mcp[cli]"
# 方式 B:用传统 venv + pip
python -m venv .venv
# Windows: .venv\Scripts\activate
source .venv/bin/activate
pip install "mcp[cli]"
# 验证装好了没(不报错即 OK)
python -c "from mcp.server.fastmcp import FastMCP; print('MCP SDK OK')"
mcp[cli] 的版本钉在 1.26.0 以上。MCP 规范按日期迭代,SDK 版本过旧可能出现协议不匹配,装完用 pip show mcp 看一眼版本号。Step 2:写一个最小可跑的 Server
新建 server.py,把下面这段原样抄进去。它定义了一个「工单查询」服务,暴露三个工具:
# server.py —— 一个最小可用的 MCP Server
from mcp.server.fastmcp import FastMCP
# ① 创建 Server 实例,名字只是标识,随便起
mcp = FastMCP("ticket-service")
# ② 用装饰器注册工具:函数名=工具名,类型注解=参数 schema,
# docstring=给模型看的用途说明(非常重要,见下方 warn)
@mcp.tool()
def get_ticket(ticket_id: str) -> dict:
"""根据工单号查询工单详情,返回状态、负责人和更新时间。
Args:
ticket_id: 工单编号,例如 "T-10086"。
"""
# 真实场景这里换成你的数据库/内部 API 调用
db = {
"T-10086": {"status": "处理中", "owner": "小王", "updated": "2026-09-12"},
"T-10087": {"status": "已关闭", "owner": "小李", "updated": "2026-09-10"},
}
if ticket_id not in db:
# 抛异常 → SDK 会转成模型能读懂的 JSON-RPC 错误
raise ValueError("未找到工单 %s,请确认编号是否正确" % ticket_id)
return db[ticket_id]
@mcp.tool()
def list_open_tickets(owner: str = "") -> list:
"""列出未关闭的工单;传入 owner 可按负责人过滤。"""
rows = [
{"id": "T-10086", "owner": "小王", "status": "处理中"},
{"id": "T-10088", "owner": "小王", "status": "待受理"},
]
return [r for r in rows if (not owner or r["owner"] == owner)]
# ③ 启动:默认走 stdio(标准输入输出)传输
if __name__ == "__main__":
mcp.run()
docstring 不是注释,它就是给模型的 API 契约。模型只看函数名、参数类型和这段文字来决定「什么时候调用、怎么传参」。写「查询工单」远比写「TODO」有用;参数描述越具体,模型越不容易传错。
Step 3:先用 Inspector 调通,别急着接模型
新手最容易犯的错是「一上来就挂进客户端,然后让模型去试错」。正确顺序是:先用官方 Inspector 单独验证工具本身没问题,把「工具坏了」和「模型不会用工具」这两类问题分开。
# 用官方 Inspector 直接连你的 server.py
npx @modelcontextprotocol/inspector python server.py
# 浏览器会打开一个本地 UI(默认 http://localhost:5173)
# 1. 左侧能看到 tools/list 里列出 get_ticket、list_open_tickets
# 2. 选中 get_ticket,参数填 {"ticket_id": "T-10086"},点 Run
# 3. 右侧看原始 JSON-RPC 返回;再故意传个不存在的编号,看错误长什么样
Step 4:加上 Resources 与 Prompts
Tools 是模型主动调用;Resources 是客户端可以按需附加到上下文的数据;Prompts 是可复用的提示词模板。同一套装饰器,写法一样:
# 资源:用 URI 模板寻址,客户端可把它塞进模型上下文
@mcp.resource("ticket://{ticket_id}/history")
def ticket_history(ticket_id: str) -> str:
"""返回某工单最近的处理记录。"""
return "2026-09-11 已受理;2026-09-12 转技术组"
# 提示词:客户端会把它显示成一条可直接使用的命令
@mcp.prompt()
def triage(ticket_id: str) -> str:
"""生成一段「工单分级」的提示词模板。"""
return ("请阅读工单 %s 的历史记录,判断它属于"
"「功能缺陷 / 使用咨询 / 数据问题」中的哪一类,"
"并给出下一步处理建议。" % ticket_id)
一个必须知道的坑:stdio 模式下,标准输出就是协议通道。任何 print() 都会污染 JSON-RPC 数据流,客户端会「静默连不上」,而且不报有用的错。调试日志一律走 sys.stderr,或者干脆用 logging 输出到 stderr。
Step 5:挂进客户端
本地 stdio 型 Server 由宿主作为子进程拉起,所以你要在宿主的配置里写清楚「用什么命令启动它」。以 Claude Desktop 为例:
// claude_desktop_config.json
// macOS: ~/Library/Application Support/Claude/
// Windows: %APPDATA%\Claude\
{
"mcpServers": {
"ticket-service": {
"command": "python",
"args": ["C:/absolute/path/to/server.py"],
"env": { "TICKET_DB_URL": "postgres://..." }
}
}
}
三个要点:
- 路径一律写绝对路径——宿主不会在你以为的目录下启动进程,相对路径是最常见的失败原因。
- 密钥走
env,不要写死在代码里,也别提交进 git。 - 改完配置要彻底退出并重启客户端,只关窗口不算重启。
%APPDATA%\Claude\logs(macOS 为 ~/Library/Logs/Claude),里面有一个总的 mcp.log 和每个 Server 各自的 stderr 日志。最快的排查方式是把配置里的 command + args 原样贴到终端手动跑一遍。Step 6:什么时候该从 stdio 换成远程
stdio 适合「个人 + 本机」,Server 只服务一个客户端,不暴露网络。一旦要团队共用、或让数据实时来自业务系统,就该换成 Streamable HTTP:一份部署代替每人装一遍,凭证集中管理,调用可审计。传输方式换了,消息格式(JSON-RPC 2.0)不变,工具代码基本不用改。
| 场景 | 推荐传输 | 理由 |
|---|---|---|
| 个人笔记本上的小工具 | stdio | 零网络暴露,最简单 |
| 团队共用的内部系统接口 | Streamable HTTP | 一次部署、集中鉴权、可审计 |
| 对公网开放的能力 | Streamable HTTP + 鉴权 | 必须每请求带 token,做限流与日志 |
安全底线:① 只连接你信任的 Server,恶意的 Server 可以「描述一套、执行另一套」;② 给最小权限——最窄的目录、最小的 scope、最弱的密钥;③ 写操作和删除操作必须留人工确认,别让 Agent 无人值守地跑;④ 远程部署按生产服务对待:容器化、鉴权、限流、逐次记录调用者身份。
常见问题速查
| 现象 | 大概率原因 & 解决 |
|---|---|
| 客户端里看不到我的工具 | 先用 Inspector 确认 Server 能列工具;再查配置里的路径是否为绝对路径 |
| 连接「莫名其妙」断开 | 代码里有 print() 污染了 stdout,改成输出到 stderr |
| 模型总是传错参数 | docstring 太模糊,把「何时用、每个参数是什么」写清楚 |
| 工具报错后模型不会重试 | 抛异常时给出可读信息(「编号不存在,应形如 T-10086」),别只丢堆栈 |
| 返回内容太长把上下文塞满 | 工具只返回结构化摘要,别把整张表原样 JSON 出去 |