从零搭建 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 的先装一下:
| 1 | curl -LsSf https://astral.sh/uv/install.sh | sh
|
装好之后创建项目:
| 1 | uv init my-mcp-server
|
| 2 | cd my-mcp-server
|
| 3 | uv add "mcp[cli]"
|
就这三行。mcp[cli] 会把 MCP Python SDK 和命令行工具一起装上。
如果你坚持用 pip 也行:
但我真心推荐 uv。速度快,依赖解析靠谱,不会出现 pip 那种"装了半天发现版本冲突"的糟心事。
FastMCP:三分钟搞出一个能用的服务器
FastMCP 是 MCP Python SDK 提供的高层封装。用它写 MCP 服务器,跟写 Flask 路由差不多简单。
先来个最基础的例子:
| 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 模型靠这个描述来决定什么时候调用你的工具。
运行它:
默认用 stdio 传输,也就是通过标准输入输出跟客户端通信。这种方式适合本地使用,不需要开端口。
MCP 的三个核心概念
在继续写代码之前,先搞清楚 MCP 的三个核心概念。这个很重要,不然你会搞混什么时候该用哪个。
Tool(工具)
Tool 是最常用的。它让 AI 能"做事"——调用 API、写文件、查数据库、发消息,任何有副作用的操作都该做成 Tool。
| 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 请求。它不应该有副作用,就是读数据。
| 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 会很方便:
| 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 能帮你管理任务。
| 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 连接):
| 1 |
|
| 2 | # 修改 server.py 的最后一行
|
| 3 | if __name__ == "__main__":
|
| 4 | mcp.run(transport="streamable-http")
|
| 5 |
|
然后启动 Inspector:
| 1 | npx -y @modelcontextprotocol/inspector
|
| 2 |
|
浏览器会打开一个界面,连接到 http://localhost:8000/mcp,你就能看到所有注册的 Tool、Resource 和 Prompt,还能直接调用测试。
我第一次写 MCP 服务器的时候不知道有这个工具,直接往 Claude Code 里怼,结果报错了也不知道哪里出问题。后来发现 Inspector,调试效率直接翻倍。
接入 Claude Code
测试没问题之后,接入 Claude Code 就一行命令:
| 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:
| 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。在配置文件里加上:
| 1 | mcp:
|
| 2 | servers:
|
| 3 | task-manager:
|
| 4 | url: http://localhost:8000/mcp
|
| 5 | transport: streamable-http
|
| 6 |
|
或者用 stdio 方式:
| 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(生命周期管理),可以在启动时连接数据库,关闭时断开。
| 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 服务器跑在后台,出了问题你看不到输出。加个日志:
| 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:
| 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 客户端没那么宽容。
解决办法很简单,确保返回值是字符串:
| 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 传过来的参数不一定靠谱。做好校验:
| 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 部署:
| 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 跑起来:
| 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 |
|
客户端连接的时候用服务器地址:
| 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 服务器:
- 一个对接 Airtable 的,让 AI 能直接操作我的内容管理表
- 一个对接微信公众号 API 的,让 AI 帮我管理文章
- 一个系统监控的,查 CPU、内存、磁盘使用情况
等搞出来再写文章分享。有啥问题评论区聊。
相关资源: