$catMANUAL||~33 min

从零搭建 MCP 服务器:手把手教你给 AI Agent 接上任意工具

advertisement

从零搭建 MCP 服务器:手把手教你给 AI Agent 接上任意工具

之前写过一篇 MCP 的科普文,讲了 MCP 到底是什么、为什么它重要。那篇文章留言区有人问:"道理我都懂了,但怎么自己写一个?"好问题。今天就来搞这个。

说实话,我第一次搭 MCP 服务器的时候翻车了。不是协议本身难,是文档写得太"标准"了——每个概念都解释得很清楚,但你就是不知道从哪下手。就像给你一本菜谱,食材清单列了二十种,但没告诉你先热锅还是先切菜。

后来我花了大概两天时间,踩了一堆坑,终于搞明白了。今天把整个过程写出来,省得你再走我的弯路。

先说清楚我们要干什么

MCP(Model Context Protocol)是一个协议,让 AI 模型能通过标准化的方式调用外部工具。你可以把它理解成 AI 世界的 USB 接口——以前每个工具都要写专门的对接代码,现在有了 MCP,写一次就能被所有支持 MCP 的 AI 客户端用。

我们要做的事情很简单:写一个 MCP 服务器,暴露几个工具(tools),让 Claude Code、Hermes Agent 之类的客户端能调用它。

听起来高大上?其实代码量很少。FastMCP 这个 Python 框架把大部分脏活都干了,你只需要关心"我想让 AI 能干什么"。

环境准备

我用的是 Python 3.11+,推荐用 uv 管理依赖。没装 uv 的先装一下:

bash
1
curl -LsSf https://astral.sh/uv/install.sh | sh

装好之后创建项目:

bash
1
uv init my-mcp-server
2
cd my-mcp-server
3
uv add "mcp[cli]"

就这三行。mcp[cli] 会把 MCP Python SDK 和命令行工具一起装上。

如果你坚持用 pip 也行:

bash
1
pip install "mcp[cli]"

但我真心推荐 uv。速度快,依赖解析靠谱,不会出现 pip 那种"装了半天发现版本冲突"的糟心事。

FastMCP:三分钟搞出一个能用的服务器

FastMCP 是 MCP Python SDK 提供的高层封装。用它写 MCP 服务器,跟写 Flask 路由差不多简单。

先来个最基础的例子:

python
1
from mcp.server.fastmcp import FastMCP
2
 
3
mcp = FastMCP("我的第一个MCP服务器")
4
 
5
@mcp.tool()
6
def add(a: int, b: int) -> int:
7
    """两数相加"""
8
    return a + b
9
 
10
if __name__ == "__main__":
11
    mcp.run()

没错,就这么点代码。@mcp.tool() 装饰器把一个普通函数变成 MCP 工具。函数的 docstring 会自动变成工具的描述,AI 模型靠这个描述来决定什么时候调用你的工具。

运行它:

bash
1
uv run server.py

默认用 stdio 传输,也就是通过标准输入输出跟客户端通信。这种方式适合本地使用,不需要开端口。

MCP 的三个核心概念

在继续写代码之前,先搞清楚 MCP 的三个核心概念。这个很重要,不然你会搞混什么时候该用哪个。

Tool(工具)

Tool 是最常用的。它让 AI 能"做事"——调用 API、写文件、查数据库、发消息,任何有副作用的操作都该做成 Tool。

python
1
@mcp.tool()
2
def search_users(query: str, limit: int = 10) -> str:
3
    """搜索用户
4
    
5
    Args:
6
        query: 搜索关键词
7
        limit: 返回数量上限
8
    """
9
    # 这里调用你的用户搜索逻辑
10
    results = do_user_search(query, limit)
11
    return json.dumps(results, ensure_ascii=False)

注意 docstring 里的 Args 部分,这不是摆设。AI 模型会读这些描述来理解每个参数的含义。写得好,模型调用就准确;写得烂,模型就瞎猜。

Resource(资源)

Resource 用来给 AI 提供数据,类似 REST API 里的 GET 请求。它不应该有副作用,就是读数据。

python
1
@mcp.resource("config://app/settings")
2
def get_app_settings() -> str:
3
    """获取应用配置"""
4
    return json.dumps({
5
        "theme": "dark",
6
        "language": "zh-CN",
7
        "version": "1.0.0"
8
    })
9
 
10
@mcp.resource("users://{user_id}/profile")
11
def get_user_profile(user_id: str) -> str:
12
    """获取用户资料"""
13
    user = db.get_user(user_id)
14
    return json.dumps(user, ensure_ascii=False)

Resource 用 URI 模板来标识,支持路径参数。AI 客户端可以列出可用的 Resource,然后按需读取。

Prompt(提示模板)

Prompt 是预定义的提示词模板。说实话这个用得比较少,大多数场景下 Tool 和 Resource 就够了。但如果你想让 AI 按特定格式处理某些任务,Prompt 会很方便:

python
1
@mcp.prompt()
2
def code_review(code: str, language: str = "python") -> str:
3
    """生成代码审查提示"""
4
    return f"""请审查以下 {language} 代码,关注:
5
1. 潜在的 bug
6
2. 性能问题
7
3. 代码风格
8
 
9
代码:
10
```{language}
11
{code}
12
```"""

实战:写一个真正有用的 MCP 服务器

计算器太无聊了。来写个有点实际用处的——一个项目管理工具的 MCP 服务器。假设你在做一个简单的 TODO 系统,想让 AI 能帮你管理任务。

python
1
import json
2
import os
3
from datetime import datetime
4
from mcp.server.fastmcp import FastMCP
5
 
6
mcp = FastMCP("任务管理器")
7
 
8
# 数据文件路径
9
DATA_FILE = os.path.expanduser("~/.task_manager/tasks.json")
10
 
11
def load_tasks() -> list[dict]:
12
    """加载任务列表"""
13
    if not os.path.exists(DATA_FILE):
14
        return []
15
    with open(DATA_FILE, "r", encoding="utf-8") as f:
16
        return json.load(f)
17
 
18
def save_tasks(tasks: list[dict]) -> None:
19
    """保存任务列表"""
20
    os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
21
    with open(DATA_FILE, "w", encoding="utf-8") as f:
22
        json.dump(tasks, f, ensure_ascii=False, indent=2)
23
 
24
@mcp.tool()
25
def add_task(title: str, priority: str = "medium", due_date: str = "") -> str:
26
    """添加新任务
27
    
28
    Args:
29
        title: 任务标题
30
        priority: 优先级,可选 high/medium/low
31
        due_date: 截止日期,格式 YYYY-MM-DD,可选
32
    """
33
    tasks = load_tasks()
34
    task = {
35
        "id": len(tasks) + 1,
36
        "title": title,
37
        "priority": priority,
38
        "due_date": due_date,
39
        "status": "pending",
40
        "created_at": datetime.now().isoformat()
41
    }
42
    tasks.append(task)
43
    save_tasks(tasks)
44
    return f"任务已添加:#{task['id']} {title}"
45
 
46
@mcp.tool()
47
def list_tasks(status: str = "all") -> str:
48
    """查看任务列表
49
    
50
    Args:
51
        status: 筛选状态,可选 all/pending/done
52
    """
53
    tasks = load_tasks()
54
    if status != "all":
55
        tasks = [t for t in tasks if t["status"] == status]
56
    
57
    if not tasks:
58
        return "没有任务"
59
    
60
    lines = []
61
    for t in tasks:
62
        icon = "✅" if t["status"] == "done" else "⬜"
63
        lines.append(f"{icon} #{t['id']} [{t['priority']}] {t['title']}")
64
    return "\n".join(lines)
65
 
66
@mcp.tool()
67
def complete_task(task_id: int) -> str:
68
    """完成任务
69
    
70
    Args:
71
        task_id: 任务 ID
72
    """
73
    tasks = load_tasks()
74
    for t in tasks:
75
        if t["id"] == task_id:
76
            t["status"] = "done"
77
            t["completed_at"] = datetime.now().isoformat()
78
            save_tasks(tasks)
79
            return f"任务 #{task_id} 已完成:{t['title']}"
80
    return f"找不到任务 #{task_id}"
81
 
82
@mcp.tool()
83
def delete_task(task_id: int) -> str:
84
    """删除任务
85
    
86
    Args:
87
        task_id: 任务 ID
88
    """
89
    tasks = load_tasks()
90
    original_count = len(tasks)
91
    tasks = [t for t in tasks if t["id"] != task_id]
92
    if len(tasks) == original_count:
93
        return f"找不到任务 #{task_id}"
94
    save_tasks(tasks)
95
    return f"任务 #{task_id} 已删除"
96
 
97
@mcp.resource("tasks://stats")
98
def get_task_stats() -> str:
99
    """获取任务统计"""
100
    tasks = load_tasks()
101
    total = len(tasks)
102
    done = sum(1 for t in tasks if t["status"] == "done")
103
    pending = total - done
104
    high_priority = sum(1 for t in tasks if t["priority"] == "high" and t["status"] == "pending")
105
    
106
    return json.dumps({
107
        "total": total,
108
        "done": done,
109
        "pending": pending,
110
        "high_priority_pending": high_priority,
111
        "completion_rate": f"{done/total*100:.1f}%" if total > 0 else "N/A"
112
    }, ensure_ascii=False)
113
 
114
if __name__ == "__main__":
115
    mcp.run()

这个服务器有四个工具和一个资源。你可以用自然语言跟 AI 说"帮我加个任务:明天之前写完报告,优先级高",AI 就会调用 add_task 工具帮你操作。

用 MCP Inspector 测试

写完服务器之后,先别急着接入客户端。MCP 官方提供了一个调试工具叫 Inspector,可以在浏览器里可视化测试你的服务器。

先启动服务器(用 streamable-http 传输,Inspector 需要 HTTP 连接):

python
1
 
2
# 修改 server.py 的最后一行
3
if __name__ == "__main__":
4
    mcp.run(transport="streamable-http")
5
 

然后启动 Inspector:

bash
1
npx -y @modelcontextprotocol/inspector
2
 

浏览器会打开一个界面,连接到 http://localhost:8000/mcp,你就能看到所有注册的 Tool、Resource 和 Prompt,还能直接调用测试。

我第一次写 MCP 服务器的时候不知道有这个工具,直接往 Claude Code 里怼,结果报错了也不知道哪里出问题。后来发现 Inspector,调试效率直接翻倍。

接入 Claude Code

测试没问题之后,接入 Claude Code 就一行命令:

bash
1
claude mcp add --transport http task-manager http://localhost:8000/mcp
2
 

加完之后重启 Claude Code,你就能在对话里直接用任务管理功能了。比如你说"帮我看看还有什么任务没完成",Claude Code 会自动调用 list_tasks 工具。

如果你想用 stdio 传输(不走 HTTP),配置方式不太一样。编辑 ~/.claude/claude_code_config.json

json
1
{
2
  "mcpServers": {
3
    "task-manager": {
4
      "command": "uv",
5
      "args": ["run", "python", "/path/to/server.py"]
6
    }
7
  }
8
}
9
 

这种方式 Claude Code 会直接启动你的服务器进程,通过 stdin/stdout 通信。适合本地工具,不需要网络。

接入 Hermes Agent

Hermes Agent 也支持 MCP。在配置文件里加上:

yaml
1
mcp:
2
  servers:
3
    task-manager:
4
      url: http://localhost:8000/mcp
5
      transport: streamable-http
6
 

或者用 stdio 方式:

yaml
1
mcp:
2
  servers:
3
    task-manager:
4
      command: uv
5
      args: ["run", "python", "/path/to/server.py"]
6
      transport: stdio
7
 

配好之后 Hermes Agent 就能调用你写的工具了。实际用起来感觉挺爽的——你跟 AI 说"帮我把写博客这个任务标记完成",它就直接操作了,不用你手动去改 JSON 文件。

进阶:连接数据库

上面的例子用 JSON 文件存数据,实际项目中你多半会用数据库。MCP 服务器支持 lifespan(生命周期管理),可以在启动时连接数据库,关闭时断开。

python
1
import sqlite3
2
from collections.abc import AsyncIterator
3
from contextlib import asynccontextmanager
4
from dataclasses import dataclass
5
from mcp.server.fastmcp import Context, FastMCP
6
 
7
@dataclass
8
class AppContext:
9
    db: sqlite3.Connection
10
 
11
@asynccontextmanager
12
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
13
    """管理数据库连接生命周期"""
14
    db = sqlite3.connect("tasks.db")
15
    db.execute("""
16
        CREATE TABLE IF NOT EXISTS tasks (
17
            id INTEGER PRIMARY KEY AUTOINCREMENT,
18
            title TEXT NOT NULL,
19
            priority TEXT DEFAULT 'medium',
20
            status TEXT DEFAULT 'pending',
21
            created_at TEXT,
22
            completed_at TEXT
23
        )
24
    """)
25
    db.commit()
26
    try:
27
        yield AppContext(db=db)
28
    finally:
29
        db.close()
30
 
31
mcp = FastMCP("任务管理器", lifespan=app_lifespan)
32
 
33
@mcp.tool()
34
def add_task(title: str, priority: str = "medium", ctx: Context = None) -> str:
35
    """添加新任务"""
36
    db = ctx.request_context.lifespan_context.db
37
    cursor = db.execute(
38
        "INSERT INTO tasks (title, priority, created_at) VALUES (?, ?, ?)",
39
        (title, priority, datetime.now().isoformat())
40
    )
41
    db.commit()
42
    return f"任务已添加:#{cursor.lastrowid} {title}"
43
 
44
@mcp.tool()
45
def list_tasks(status: str = "all", ctx: Context = None) -> str:
46
    """查看任务列表"""
47
    db = ctx.request_context.lifespan_context.db
48
    if status == "all":
49
        rows = db.execute("SELECT * FROM tasks").fetchall()
50
    else:
51
        rows = db.execute("SELECT * FROM tasks WHERE status = ?", (status,)).fetchall()
52
    
53
    if not rows:
54
        return "没有任务"
55
    
56
    lines = []
57
    for row in rows:
58
        icon = "✅" if row[3] == "done" else "⬜"
59
        lines.append(f"{icon} #{row[0]} [{row[2]}] {row[1]}")
60
    return "\n".join(lines)
61
 

注意 ctx: Context 参数——FastMCP 会自动注入,你不需要手动传。通过 ctx.request_context.lifespan_context 就能拿到在 lifespan 里初始化的数据库连接。

Streamable HTTP vs SSE vs Stdio

MCP 支持三种传输方式,选哪个取决于你的使用场景:

Stdio:最简单,客户端直接启动你的服务器进程。适合本地工具,不需要网络配置。Claude Code 默认用这种方式。

SSE(Server-Sent Events):基于 HTTP 的单向推送。MCP 早期版本用这个,现在被 Streamable HTTP 取代了。如果你看到老教程里用 SSE,可以换成 Streamable HTTP。

Streamable HTTP:最新的传输方式,支持双向通信。适合远程部署、多客户端共享的场景。如果你想把 MCP 服务器部署到服务器上给团队用,选这个。

实际用下来,本地开发用 stdio 最省事,部署到服务器用 Streamable HTTP。SSE 基本可以忽略了。

调试技巧

写 MCP 服务器的时候,调试是个绕不开的话题。分享几个我踩坑总结出来的经验。

日志很重要。MCP 服务器跑在后台,出了问题你看不到输出。加个日志:

python
1
import logging
2
 
3
logging.basicConfig(level=logging.DEBUG)
4
logger = logging.getLogger("mcp-server")
5
 
6
@mcp.tool()
7
def my_tool(query: str) -> str:
8
    logger.info(f"收到请求:{query}")
9
    try:
10
        result = do_something(query)
11
        logger.info(f"返回结果:{result}")
12
        return result
13
    except Exception as e:
14
        logger.error(f"出错了:{e}")
15
        raise
16
 

参数类型要严格。MCP 协议会根据你函数的类型注解来校验参数。如果你写 def foo(x: int) 但传了个字符串进来,会报错。别想着"反正 Python 不强制类型"——MCP 强制。

错误处理要友好。工具执行失败的时候,返回一个有意义的错误消息,别让 AI 看到一堆 traceback:

python
1
@mcp.tool()
2
def risky_operation(path: str) -> str:
3
    """执行一个可能失败的操作"""
4
    try:
5
        result = do_something_risky(path)
6
        return f"成功:{result}"
7
    except FileNotFoundError:
8
        return f"错误:找不到文件 {path}"
9
    except PermissionError:
10
        return f"错误:没有权限访问 {path}"
11
    except Exception as e:
12
        return f"错误:{str(e)}"
13
 

用 Inspector 测试边界情况。比如传空字符串、超大数字、特殊字符,看看你的工具能不能正确处理。AI 用户的输入比你想象的更"有创意"。

一个真实的踩坑经历

上周我在写一个 MCP 服务器,功能是查询 GitHub 仓库信息。代码写好了,Inspector 里测试一切正常,但接入 Claude Code 之后死活不工作。

排查了半天,发现问题出在函数返回值上。我返回的是一个 Python 对象,不是字符串。MCP 协议要求工具返回值必须是字符串(或者可以序列化为 JSON 的东西)。Inspector 里能正常显示是因为它会自动处理,但 Claude Code 的 MCP 客户端没那么宽容。

解决办法很简单,确保返回值是字符串:

python
1
@mcp.tool()
2
def get_repo_info(owner: str, repo: str) -> str:
3
    """获取 GitHub 仓库信息"""
4
    info = github_api.get_repo(owner, repo)
5
    return json.dumps(info, ensure_ascii=False)  # 必须序列化为字符串
6
 

这种问题文档里写了,但写得不够醒目。我当时扫了一眼没注意,浪费了两个小时。

安全注意事项

MCP 服务器本质上是在执行外部请求,安全问题不能忽视。

别暴露危险操作。如果你的工具有删文件、执行命令之类的功能,一定要加权限检查。AI 模型有时候会"创造性"地理解你的工具描述,做出你意想不到的操作。

验证输入参数。AI 传过来的参数不一定靠谱。做好校验:

python
1
@mcp.tool()
2
def delete_file(path: str) -> str:
3
    """删除指定文件"""
4
    # 必须验证路径!
5
    if not path.startswith("/safe/directory/"):
6
        return "错误:只能删除安全目录下的文件"
7
    if ".." in path:
8
        return "错误:路径不能包含 .."
9
    # ... 执行删除
10
 

考虑速率限制。如果你的工具调用外部 API,加个速率限制,防止 AI 在循环里疯狂调用把你的 API 额度用光。

部署到服务器

本地开发用 stdio 就够了,但如果要给团队用或者让多个客户端共享,就需要部署到服务器。

用 Streamable HTTP 传输,配合 uvicorn 部署:

python
1
 
2
# server.py
3
from mcp.server.fastmcp import FastMCP
4
 
5
mcp = FastMCP("团队工具服务器")
6
 
7
@mcp.tool()
8
def team_tool(query: str) -> str:
9
    """团队共享工具"""
10
    return f"处理结果:{query}"
11
 
12
if __name__ == "__main__":
13
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
14
 

然后用 systemd 或者 Docker 跑起来:

bash
1
 
2
# 用 systemd
3
sudo systemctl enable my-mcp-server
4
sudo systemctl start my-mcp-server
5
 
6
# 或者用 Docker
7
docker run -d -p 8000:8000 my-mcp-server
8
 

客户端连接的时候用服务器地址:

bash
1
claude mcp add --transport http team-tools http://your-server:8000/mcp
2
 

还能干什么

MCP 的玩法远不止这些。我见过有人用它做:

  • 数据库查询工具:让 AI 直接查 SQL 数据库,不用手动写查询
  • API 网关:把多个第三方 API 封装成 MCP 工具,AI 统一调用
  • 文件管理:让 AI 读写本地文件、管理项目结构
  • 监控告警:AI 定时检查服务器状态,有问题自动通知
  • 代码生成器:根据模板自动生成代码片段

核心思路就是:你想让 AI 帮你做的事,都可以封装成 MCP 工具。

下一步打算

后面我打算搞几个更实用的 MCP 服务器:

  1. 一个对接 Airtable 的,让 AI 能直接操作我的内容管理表
  2. 一个对接微信公众号 API 的,让 AI 帮我管理文章
  3. 一个系统监控的,查 CPU、内存、磁盘使用情况

等搞出来再写文章分享。有啥问题评论区聊。

相关资源:

advertisement

从零搭建 MCP 服务器:手把手教你给 AI Agent 接上任意工具 — AI Hub