进阶 📋 6 个步骤 第 413 / 470 篇

从零写一个 MCP Server:用 Python 把内部工具变成 Agent 能调的标准接口

用 Python 官方 SDK 的 FastMCP 从零写一个 MCP Server,把内部工具暴露给 Agent,并用官方 Inspector 调通、挂进客户端。

2026.09.12· 22 分钟阅读· 约 2163 字· 🔌 MCP / 🐍 Python

如果 MCP 是 Agent 和外部世界之间的「USB-C 接口」,那么 MCP Server 就是那根线后面的驱动程序。前面几篇教程讲的是怎么用别人写好的 Server(把文件、数据库接给助手),这一篇反过来:自己写一个 Server,把你们内部的接口变成所有 Agent 都能调用的标准工具。全程 Python,从空目录到一个能被客户端识别的服务。

🔌 本教程适合:有基础 Python 能力、想让 Agent 调用内部系统(订单查询、工单、报表)的开发者。需要 Python 3.10+,一台能联网的电脑。不需要 GPU。

先搞懂:MCP 里的三个角色

在看代码前,先把三个名词对上号,后面就不会乱:

角色是什么例子
Host(宿主)用户直接操作的 AI 应用Claude Desktop、Cursor、你自己的 Agent 平台
Client(客户端)宿主内部负责连 Server 的组件,1 对 1 保持连接SDK 里帮你握手的那一层
Server(服务端)你把能力包起来的那段轻量程序本篇要写的 server.py

一个 Server 对外可以暴露三种「原语」:Tools(可被模型调用的函数,最常用)、Resources(只读数据,由客户端决定何时塞进上下文)、Prompts(预置提示词模板)。本教程三条都会覆盖,但重心放在 Tools 上。

Step 1:准备环境

1 装官方 Python SDK(FastMCP)

官方 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

2 三个工具,20 行代码

新建 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 调通,别急着接模型

3 不接 LLM,直接手工调你的工具

新手最容易犯的错是「一上来就挂进客户端,然后让模型去试错」。正确顺序是:先用官方 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 返回;再故意传个不存在的编号,看错误长什么样
✅ 判断标准:工具能列出、能返回、错误信息可读,这三条都过了,再往下走。Inspector 是本地工具,不会把数据发给任何大模型。

Step 4:加上 Resources 与 Prompts

4 除了「动手」,还能「给资料」和「给模板」

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:挂进客户端

5 让 Claude Desktop / 兼容客户端认识它

本地 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://..." }
    }
  }
}

三个要点:

  1. 路径一律写绝对路径——宿主不会在你以为的目录下启动进程,相对路径是最常见的失败原因。
  2. 密钥走 env,不要写死在代码里,也别提交进 git。
  3. 改完配置要彻底退出并重启客户端,只关窗口不算重启。
🔎 连不上时不要靠猜:Claude Desktop 的日志在 %APPDATA%\Claude\logs(macOS 为 ~/Library/Logs/Claude),里面有一个总的 mcp.log 和每个 Server 各自的 stderr 日志。最快的排查方式是把配置里的 command + args 原样贴到终端手动跑一遍。

Step 6:什么时候该从 stdio 换成远程

6 一台笔记本 vs 一个团队

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 出去
📌 下一站:把 Server 部署到远程、加上鉴权,或者用同样的思路把公司内部 API 批量「MCP 化」。等你手上有一组稳定的 MCP Server,Agent 能做的事就从「聊」变成了「干活」。
← 返回教程中心