MCP Server 开发实战:从 0 到 1 构建自己的工具服务

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 最成熟,我们以此为基础。

bash 复制代码
pip install mcp httpx httpx-sse

MCP Server 本质上是一个运行在子进程中的 JSON-RPC 服务,通过 stdin/stdout 与 Client 通信(本地模式),也可以走 SSE 或 WebSocket(远程模式)。本文采用本地模式,这是最直接、最稳定的方式。

实战:构建一个 GitHub Issue 管理 Server

为了有足够的实战感,我们构建一个能操作 GitHub Issue 的 MCP Server。它提供三个 Tool:

  1. get_issue:获取 Issue 详情
  2. search_issues:搜索 Issue
  3. create_issue:创建 Issue

基础骨架

python 复制代码
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:

python 复制代码
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 后,需要实现执行逻辑:

python 复制代码
@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 的上下文窗口撑爆。

完整的数据获取函数

python 复制代码
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 中添加:

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 作为常驻进程可以自然持有状态:

python 复制代码
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 工具测试:

bash 复制代码
pip install mcp-cli
mcp-cli run /path/to/github_issue_server.py

这会启动一个交互式命令行,你可以模拟 LLM 调用 Tool:

shell 复制代码
> 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)做远程传输:

python 复制代码
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 端配置变为:

json 复制代码
{"mcpServers": {"github-issue": {"url": "https://your-server.com/mcp/sse"}}}

总结

MCP Server 的开发门槛其实不高------本质就是写一个 JSON-RPC 服务,把现有的 API 或能力包装成 LLM 可调用的 Tool。但有几个关键点值得反复强调:

  1. Tool 的描述比实现更重要------它决定了 LLM 何时、如何调用你的 Server
  2. 注意上下文窗口管理------返回内容适度截断,不要把 LLM 的上下文撑爆
  3. 调试走 stderr 不走 stdout------协议通信走 stdout,print 会搞坏连接
  4. 状态管理按需选择------无状态简单可靠,有状态灵活但要注意并发安全

MCP 生态还在快速演进中,SDK 几乎每周都有更新。但核心的 Tools/Resources/Prompts 三层抽象已经非常稳定,值得投入学习。下一篇文章我会深入 MCP 的 Transport 层,聊聊自定义传输协议和认证机制的实现方案,敬请期待。

相关推荐
AI职业加油站1 小时前
2026大数据运维行业趋势复盘:大数据运维工程师赋能发展
大数据·运维·人工智能·学习·数据分析·职场发展
洛阳纸贵1 小时前
AI-PyTorch(一)基础代码实操和自动求导
人工智能·pytorch·python
妙码生花1 小时前
golang 应用服务端部署(使用 systemd 服务)
开发语言·人工智能·后端·golang·node.js·php·gin
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<三十八>:图像添加数字水印
c++·人工智能·opencv·计算机视觉
智圣新创011 小时前
第二课堂育人体系数字化落地:从需求对齐到价值释放的全域建设路径
人工智能
GlobalInfo1 小时前
34.2%年复合增长率背后,AI量子计算市场规模影响因素会是什么?
大数据·人工智能·量子计算
智驭未来掌门人1 小时前
从LLM Gateway到Agent Gateway:大模型网关架构演进的技术分析
人工智能
RisunJan1 小时前
DeepSeek 全景技术指南:从混合推理架构到提示语工程实战(2026.09)
大数据·人工智能·架构