MCP Server 开发实战:从 0 到 1 构建自己的工具服务
前言
MCP(Model Context Protocol)协议自 Anthropic 开源以来,正在快速成为 AI 应用与外部工具之间的标准通信协议。如果说 MCP 是"AI 应用的 USB-C",那 MCP Server 就是这个"USB-C"接口背后的设备驱动。没有 Server,协议就是个空壳。
在过去几个月里,我基于 MCP 协议为团队构建了几个自定义工具服务,从最基础的天气查询到数据库操作再到代码分析,踩了不少坑,也积累了一些经验。这篇文章将带着你从零开始,手写一个完整的 MCP Server,并在实际的 Agent 场景中调用它。
MCP 协议的核心概念
在动手之前,先快速理清 MCP 协议的几个核心抽象。
MCP 采用客户端-服务端架构。MCP Host 是用户直接交互的 AI 应用(比如 Claude Desktop、Cursor、或者你自己搭建的 Agent 平台),MCP Client 是 Host 内部与 Server 建立 1:1 连接的组件,而 MCP Server 则是暴露具体能力的轻量级服务。
一个 MCP Server 对外暴露三种核心能力原语:
- Tools(工具):可被 LLM 调用的函数,类似 OpenAI 的 Function Calling。每个 Tool 有名称、描述和参数 schema。
- Resources(资源):暴露给 LLM 的结构化数据,Server 决定何时推送。类似一个只读的数据接口。
- Prompts(提示模板):预定义的 prompt 片段,帮助 LLM 更好地理解如何使用这个 Server。
对于绝大多数场景,Tools 是最常用、最重要的原语。本篇文章将聚焦于 Tool 的开发。
环境准备
首先安装 MCP 官方 SDK。Python 版本的 SDK 最成熟,我们以此为基础。
pip install mcp httpx httpx-sse
MCP Server 本质上是一个运行在子进程中的 JSON-RPC 服务,通过 stdin/stdout 与 Client 通信(本地模式),也可以走 SSE 或 WebSocket(远程模式)。本文采用本地模式,这是最直接、最稳定的方式。
实战:构建一个 GitHub Issue 管理 Server
为了有足够的实战感,我们构建一个能操作 GitHub Issue 的 MCP Server。它提供三个 Tool:
get_issue:获取 Issue 详情search_issues:搜索 Issuecreate_issue:创建 Issue
基础骨架
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
async def main():
server = Server("github-issue-manager")
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="github-issue-manager",
server_version="0.1.0",
),
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
这段代码启动了一个什么都不做的 MCP Server。麻雀虽小五脏俱全——它已经能完成 MCP 协议的握手和数据交换了。
踩坑记录:
stdlib_server()是异步上下文管理器,必须在async with内调用server.run()。我之前在外面调用导致连接一直不成功,排查了半小时才发现是生命周期问题。
注册 Tool
接下来,我们用装饰器注册 Tool:
import httpx
from mcp.server.models import Tool
from mcp.types import TextContent
GITHUB_API_BASE = "https://api.github.com"
@server.list_tools()
async def handle_list_tools() -> list[Tool]:
return [
Tool(
name="get_issue",
description="获取指定 GitHub Issue 的详细信息",
inputSchema={"type": "object", "properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "issue_number": {"type": "integer"}}, "required": ["owner", "repo", "issue_number"]},
),
Tool(
name="search_issues",
description="按关键词搜索 GitHub Issues",
inputSchema={...},
),
Tool(
name="create_issue",
description="创建 GitHub Issue",
inputSchema={...},
),
]
这里有几个关键点。Tool 的 description 不是写给开发者看的,是写给 LLM 看的。LLM 通过 description 来判断何时调用哪个 Tool,描述写得越清晰,LLM 的调用越精准。比如 "获取指定 GitHub Issue 的详细信息" 就比 "get_issue 方法" 好得多。
inputSchema 必须严格遵循 JSON Schema 格式,不能随意发挥。每个属性最好都有 description,这对 LLM 的准确填参至关重要。
Tool 执行逻辑
注册完 Tool 后,需要实现执行逻辑:
@server.call_tool()
async def handle_call_tool(
name: str, arguments: dict
) -> list[TextContent]:
token = arguments.pop("_github_token", None)
headers = {"Authorization": f"Bearer {token}"} if token else {}
headers["Accept"] = "application/vnd.github.v3+json"
async with httpx.AsyncClient(headers=headers) as client:
if name == "get_issue":
issue = await get_issue(client, **arguments)
return [TextContent(type="text", text=json.dumps(issue, indent=2))]
elif name == "search_issues":
results = await search_issues(client, **arguments)
return [TextContent(type="text", text=json.dumps(results, indent=2))]
elif name == "create_issue":
result = await create_issue(client, **arguments)
return [TextContent(type="text", text=f"Issue created: {result['html_url']}")]
else:
raise ValueError(f"Unknown tool: {name}")
注意 TextContent 这个返回值类型。MCP 目前支持的 Content 类型包括 text、resource、image 等,日常使用 TextContent 就足够了。返回的文本会直接变成 LLM 思考上下文的一部分——你返回什么,LLM 就看到什么。
踩坑记录:返回内容如果太冗长(比如一个包含几百条评论的 Issue),会占用大量上下文窗口。建议对返回内容做截断或摘要,避免把 LLM 的上下文窗口撑爆。
完整的数据获取函数
async def get_issue(
client: httpx.AsyncClient, owner: str, repo: str, issue_number: int
) -> dict:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}"
)
resp.raise_for_status()
data = resp.json()
return {
"number": data["number"],
"title": data["title"],
"state": data["state"],
"body": data["body"][:2000] if data["body"] else "",
"labels": [l["name"] for l in data["labels"]],
"assignees": [a["login"] for a in data["assignees"]],
"comments": data["comments"],
"html_url": data["html_url"],
}
配置与启动
要让这个 Server 被 AI 应用识别,需要在配置文件中注册。以 Claude Desktop 为例,在 claude_desktop_config.json 中添加:
{"mcpServers": {"github-issue": {"command": "python", "args": ["/path/to/github_issue_server.py"], "env": {"GITHUB_TOKEN": "ghp_xxx"}}}
配置完成后重启 Claude Desktop,就应该能在对话中调用这些 Tool 了。
进阶模式:带状态的 Server
上面的例子是无状态的,每次调用都独立做一次 HTTP 请求。但有些场景需要 Server 保持状态:
- 分页遍历:用户说"再翻下一页"时,Server 需要记住当前页码
- 多步操作:先创建 Issue,再添加 Label,最后 Assign 给某人
MCP Server 作为常驻进程可以自然持有状态:
class SessionManager:
def __init__(self):
self.sessions: dict[str, dict] = {}
self._lock = asyncio.Lock()
async def get_or_create(self, session_id: str) -> dict:
async with self._lock:
if session_id not in self.sessions:
self.sessions[session_id] = {"cursor": None, "history": []}
return self.sessions[session_id]
session_mgr = SessionManager()
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[TextContent]:
session = await session_mgr.get_or_create(arguments.get("session_id", "default"))
# ... 使用 session 维护状态
测试与调试
MCP Server 的调试方式比较特别——它不走 HTTP,而是走 stdin/stdout。可以用 mcp-cli 工具测试:
pip install mcp-cli
mcp-cli run /path/to/github_issue_server.py
这会启动一个交互式命令行,你可以模拟 LLM 调用 Tool:
> call get_issue {"owner": "python", "repo": "cpython", "issue_number": 123456}
如果输出为 JSON 格式的 Issue 数据,说明 Server 工作正常。
踩坑记录:MCP Server 的 stdout 被用于协议通信,所以不能在里面写 print() 调试。所有调试输出必须走 stderr(Python 的 logging 模块默认走 stderr,没问题)。如果误用了 print,Client 会解析到非法 JSON-RPC 消息而断开连接。
从本地到生产:远程 MCP Server
本地模式适合开发和调试,生产环境通常需要远程部署。MCP 支持通过 SSE(Server-Sent Events)做远程传输:
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
sse = SseServerTransport("/mcp/messages/")
async def handle_sse(request):
async with sse.connect_sse(request) as (read, write):
await server.run(read, write, InitializationOptions(...))
app = Starlette()
app.add_route("/mcp/sse", handle_sse)
app.add_route("/mcp/messages/{session_id:path}", handle_sse)
远程部署后,Client 端配置变为:
{"mcpServers": {"github-issue": {"url": "https://your-server.com/mcp/sse"}}}
总结
MCP Server 的开发门槛其实不高——本质就是写一个 JSON-RPC 服务,把现有的 API 或能力包装成 LLM 可调用的 Tool。但有几个关键点值得反复强调:
- Tool 的描述比实现更重要——它决定了 LLM 何时、如何调用你的 Server
- 注意上下文窗口管理——返回内容适度截断,不要把 LLM 的上下文撑爆
- 调试走 stderr 不走 stdout——协议通信走 stdout,print 会搞坏连接
- 状态管理按需选择——无状态简单可靠,有状态灵活但要注意并发安全
MCP 生态还在快速演进中,SDK 几乎每周都有更新。但核心的 Tools/Resources/Prompts 三层抽象已经非常稳定,值得投入学习。下一篇文章我会深入 MCP 的 Transport 层,聊聊自定义传输协议和认证机制的实现方案,敬请期待。