Model Context Protocol(MCP)是 Anthropic 在 2024 年底开源的协议,用于让 LLM 以标准化方式连接外部工具、数据源。本文先用 Python 实现一个最小可运行的 MCP Server,再详细拆解 AI 是如何知道 MCP 提供了哪些方法、如何调用它们的


一、MCP 是什么?30 秒理解

MCP = Model Context Protocol,本质是一套 JSON-RPC 2.0 协议,定义了:

  • ServerClient(通常是 LLM 客户端,如 Claude Desktop、Cursor)暴露三类能力:
    • tools —— 可调用的函数(类似 function calling)
    • resources —— 可读取的数据源(类似文件 / API)
    • prompts —— 预定义的提示词模板
  • Client 通过协议握手发现这些能力,并把它们注入 LLM 的上下文
  • LLM 决定调用某个 tool 时,Client 负责实际执行并把结果回传
┌──────────┐    MCP 协议(JSON-RPC)    ┌──────────┐
│  Client  │ ←─────────────────────→ │  Server  │
│ (Claude) │                         │ (你的代码)│
└────┬─────┘                         └────┬─────┘
     │                                    │
     │ 把 tools 列表注入 LLM 上下文         │ 执行实际函数
     │ LLM 决定调用 → Client 转发请求       │ 返回结果
     └────────────────────────────────────┘

二、环境准备

2.1 安装官方 SDK

pip install "mcp[cli]"
# 或最小依赖:
pip install mcp

官方 Python SDK 仓库:https://github.com/modelcontextprotocol/python-sdk

2.2 目录结构

mcp_demo/
├── server.py        # MCP Server 实现
└── README.md

三、最小可运行 MCP Server 示例

下面这个 Server 暴露 2 个工具:

  • add(a, b) —— 加法
  • get_weather(city) —— 模拟天气查询

3.1 完整代码(server.py)

# -*- coding: utf-8 -*-
"""
最小 MCP Server 示例
运行方式:python server.py
默认通过 stdio 与 Client 通信
"""
import sys
import json
from mcp.server.fastmcp import FastMCP

sys.stdout.reconfigure(encoding="utf-8")

# 创建 MCP Server 实例
mcp = FastMCP("demo-server")


# ============ 工具 1:加法 ============
@mcp.tool()
def add(a: float, b: float) -> str:
    """两个数字相加,返回它们的和。

    Args:
        a: 第一个数字
        b: 第二个数字
    """
    return f"{a} + {b} = {a + b}"


# ============ 工具 2:模拟天气查询 ============
@mcp.tool()
def get_weather(city: str) -> str:
    """查询某个城市的当前天气(模拟数据)。

    Args:
        city: 城市名称,如 "北京"
    """
    # 模拟数据,实际可对接真实天气 API
    mock = {
        "北京": "晴,18℃",
        "上海": "多云,22℃",
        "深圳": "雷阵雨,28℃",
    }
    return mock.get(city, f"未收录 {city} 的天气,默认晴 20℃")


# ============ 资源:静态资源示例 ============
@mcp.resource("config://app")
def get_config() -> str:
    """暴露一份配置文件作为资源"""
    return json.dumps({"version": "1.0.0", "env": "prod"}, ensure_ascii=False)


# ============ 提示词模板示例 ============
@mcp.prompt()
def code_review(code: str) -> str:
    """代码评审提示词"""
    return f"请评审以下代码,关注安全性和可读性:\n\n```\n{code}\n```"


if __name__ == "__main__":
    # 启动 Server,通过 stdio 通信
    mcp.run(transport="stdio")

3.2 代码解析

装饰器 作用 暴露给 AI 的方式
@mcp.tool() 注册工具函数 LLM 可主动调用
@mcp.resource() 注册只读资源 LLM 可读取内容
@mcp.prompt() 注册提示词模板 用户可在客户端选择

关键设计:

  • docstring 即文档:函数的 docstring 会被自动转成 tool 的 description,AI 看到的就是它
  • 类型注解即入参 schema:a: float 会被转成 JSON Schema,AI 据此知道参数类型和是否必填
  • FastMCP:官方 SDK 的高级封装,把 JSON-RPC 协议细节隐藏起来,开发者只写函数

四、运行与接入

4.1 直接运行(自检)

python server.py

进程会阻塞,等待 stdin 输入。这是正常的 —— 它在等 Client 发 JSON-RPC 消息。

4.2 接入 Claude Desktop

编辑 Claude Desktop 配置文件:

  • macOS:~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows:%APPDATA%\Claude\claude_desktop_config.json

加入:

{
  "mcpServers": {
    "demo-server": {
      "command": "python",
      "args": ["D:/path/to/server.py"]
    }
  }
}

重启 Claude Desktop,在对话框右下角会看到 🔄 工具图标,点开能看到 addget_weather

4.3 接入 Cursor / 其他支持 MCP 的客户端

原理一致 —— 在客户端的 MCP 配置里注册 Server 的启动命令。


五、AI 是如何知道 MCP 提供了哪些方法的?

这是本文的核心问题。拆成 4 个阶段:

阶段 1:启动握手 → 阶段 2:能力发现 → 阶段 3:注入上下文 → 阶段 4:按需调用

5.1 阶段一:协议握手(Initialize)

Client 启动 Server 子进程后,立刻发一条 initialize 请求:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": { "name": "claude-desktop", "version": "1.0.0" }
  }
}

Server 回:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {},
      "resources": {},
      "prompts": {}
    },
    "serverInfo": { "name": "demo-server", "version": "1.0.0" }
  }
}

关键字段:capabilities.tools / resources / prompts —— Server 在这里告诉 Client 「我有哪些类的能力」,但还没列具体方法。这就像饭店门口挂「本店有炒菜、面食、汤」的牌子,但还没给你菜单。

5.2 阶段二:能力发现(List)

握手完成后,Client 立刻请求 清单:

5.2.1 拉取工具列表 —— tools/list

Client 发:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

Server 回(基于上面的 Python 代码):

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "add",
        "description": "两个数字相加,返回它们的和。",
        "inputSchema": {
          "type": "object",
          "properties": {
            "a": { "type": "number", "description": "第一个数字" },
            "b": { "type": "number", "description": "第二个数字" }
          },
          "required": ["a", "b"]
        }
      },
      {
        "name": "get_weather",
        "description": "查询某个城市的当前天气(模拟数据)。",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "城市名称,如 \"北京\"" }
          },
          "required": ["city"]
        }
      }
    ]
  }
}

注意三个来源:

字段 来源
name Python 函数名
description 函数 docstring
inputSchema 类型注解 + 参数 docstring(Args: 段)

也就是说 —— AI 看到的工具文档,完全由你写函数时的 docstring 和类型注解决定。docstring 不写,AI 就不知道这工具干嘛,很可能不调或乱调。

5.2.2 拉取资源列表 —— resources/list
{ "jsonrpc": "2.0", "id": 3, "method": "resources/list" }

Server 回:

{
  "result": {
    "resources": [
      {
        "uri": "config://app",
        "name": "get_config",
        "description": "暴露一份配置文件作为资源",
        "mimeType": "text/plain"
      }
    ]
  }
}
5.2.3 拉取提示词列表 —— prompts/list

同理,返回所有 @mcp.prompt() 注册的模板。

5.3 阶段三:注入 LLM 上下文

Client 拿到 tools/list 的结果后,把它转换成 LLM 能理解的格式,塞进 system prompt 或 tools 字段:

5.3.1 转成 OpenAI function calling 格式
{
  "model": "gpt-4o",
  "messages": [...],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "add",
        "description": "两个数字相加,返回它们的和。",
        "parameters": {
          "type": "object",
          "properties": {
            "a": { "type": "number", "description": "第一个数字" },
            "b": { "type": "number", "description": "第二个数字" }
          },
          "required": ["a", "b"]
        }
      }
    }
  ]
}
5.3.2 转成 Anthropic tools 格式(Claude 系列)
{
  "model": "claude-opus-4-7",
  "tools": [
    {
      "name": "add",
      "description": "两个数字相加,返回它们的和。",
      "input_schema": {
        "type": "object",
        "properties": {
          "a": { "type": "number" },
          "b": { "type": "number" }
        },
        "required": ["a", "b"]
      }
    }
  ]
}

核心转换逻辑:

MCP tools/list 结果  ──[字段名映射]──>  LLM tools 字段
  name                  →  name
  description           →  description
  inputSchema           →  parameters / input_schema

不同 LLM 的 tools 字段格式略有差异,Client 负责 协议适配,Server 永远只输出 MCP 标准 schema。

5.4 阶段四:LLM 决策调用 → Client 转发 → 返回

5.4.1 LLM 输出工具调用请求

当用户问「北京天气怎么样?」,LLM 看到自己有 get_weather 工具,会输出:

{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"北京\"}"
      }
    }
  ]
}
5.4.2 Client 拦截 → 转成 MCP 请求 → 发给 Server

Client 不直接执行函数,而是通过 MCP 协议把请求转给 Server:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "北京" }
  }
}
5.4.3 Server 执行函数 → 返回结果

Server 根据名字找到 get_weather 函数,执行后回:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      { "type": "text", "text": "晴,18℃" }
    ]
  }
}
5.4.4 Client 把结果转成 LLM 消息 → 继续对话

Client 把结果塞回 LLM 上下文:

{
  "role": "tool",
  "tool_call_id": "call_abc123",
  "content": "晴,18℃"
}

LLM 看到结果,生成最终回答:「北京现在是晴天,气温 18℃。」


六、完整时序图

用户                Client(Claude)             Server(server.py)            LLM
 │                      │                           │                       │
 │ "北京天气怎么样?"     │                           │                       │
 │─────────────────────>│                           │                       │
 │                      │                           │                       │
 │                      │ 1. initialize             │                       │
 │                      │──────────────────────────>│                       │
 │                      │<──────────────────────────│                       │
 │                      │ 2. tools/list             │                       │
 │                      │──────────────────────────>│                       │
 │                      │<──────────────────────────│                       │
 │                      │   返回 [add, get_weather] │                       │
 │                      │                           │                       │
 │                      │ 3. 注入 tools 到 LLM 上下文                        │
 │                      │──────────────────────────────────────────────────>│
 │                      │                           │                       │
 │                      │ 4. LLM 决定调用 get_weather("北京")                │
 │                      │<──────────────────────────────────────────────────│
 │                      │                           │                       │
 │                      │ 5. tools/call             │                       │
 │                      │   name=get_weather        │                       │
 │                      │   args={city:"北京"}      │                       │
 │                      │──────────────────────────>│                       │
 │                      │                           │ 执行 get_weather()    │
 │                      │<──────────────────────────│                       │
 │                      │   content: "晴,18℃"      │                       │
 │                      │                           │                       │
 │                      │ 6. 把结果回传给 LLM                                │
 │                      │──────────────────────────────────────────────────>│
 │                      │                           │                       │
 │                      │ 7. LLM 生成最终回答                                │
 │                      │<──────────────────────────────────────────────────│
 │                      │                           │                       │
 │ "北京现在是晴天,18℃"│                           │                       │
 │<─────────────────────│                           │                       │

七、关键设计点:为什么是 JSON-RPC 而不是 REST?

维度 JSON-RPC(MCP 选的) REST
双向通信 ✅ Server 也可主动通知 Client(notifications/tools/list_changed) ❌ 只能 Client 主动
统一入口 一个端点,method 区分 每个能力一个 URL
传输层无关 stdio / HTTP / SSE 都能跑 通常只 HTTP
工具调用语义 method + params,贴合 RPC 语义 GET/POST 语义不对应「调用函数」

实际传输方式(transport):

  • stdio —— 本地子进程,Claude Desktop 用这种
  • HTTP + SSE —— 远程 Server,适合云端部署
  • Streamable HTTP —— 2025 年新增,单端点双向流

八、调试与验证

8.1 用官方 Inspector 工具

npx @modelcontextprotocol/inspector python server.py

打开浏览器,可视化看到:

  • 握手返回的 capabilities
  • tools/listresources/listprompts/list 的实际结果
  • 在界面里手动调 tool,看返回

8.2 不写 SDK,手写最小 MCP Server

理解协议最快的方式是手写 JSON-RPC:

# 手写最小 MCP Server(仅 tools/list 和 tools/call)
import sys
import json

def handle(req):
    method = req.get("method")
    if method == "initialize":
        return {
            "protocolVersion": "2024-11-05",
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "handwritten", "version": "1.0.0"}
        }
    if method == "tools/list":
        return {"tools": [{
            "name": "echo",
            "description": "原样返回输入",
            "inputSchema": {
                "type": "object",
                "properties": {"msg": {"type": "string"}},
                "required": ["msg"]
            }
        }]}
    if method == "tools/call":
        params = req.get("params", {})
        if params.get("name") == "echo":
            return {"content": [{"type": "text", "text": params["arguments"]["msg"]}]}
    return None

for line in sys.stdin:
    req = json.loads(line)
    result = handle(req)
    if result is not None:
        print(json.dumps({"jsonrpc": "2.0", "id": req.get("id"), "result": result}), flush=True)

跑起来和官方 SDK 暴露的能力一样,AI 看不出差别 —— 这正说明 MCP 是 协议,SDK 只是语法糖。


九、最佳实践

  1. docstring 要写清楚 —— 这是 AI 唯一能看到的工具说明,写得越具体,AI 调用越准
  2. 类型注解必须准确 —— 别用 Anydict,用具体的 str / int / Pydantic Model
  3. 工具粒度要适中 —— 太粗(run_anything)AI 不会用,太细(add_one)上下文爆掉
  4. 错误用 MCP 标准格式返回 —— {"isError": true, "content": [...]} 比 raise 异常更友好
  5. 副作用工具要幂等 —— AI 可能重复调用,确保多次调结果一致
  6. 资源优先于工具 —— 只读数据用 resource,有副作用的才用 tool
  7. prompt 用于固化高频用法 —— 比如固定的代码评审模板,不必让用户每次手敲

十、常见坑

坑 1:工具没出现在客户端

原因:@mcp.tool() 装饰器写错 / 函数没 return / docstring 为空。
排查:用 Inspector 看 tools/list 返回。

坑 2:AI 调用时参数对不上

原因:类型注解和实际接受类型不符,例如注解 str 但函数内当 int 用。
后果:AI 按注解传 "5",函数 a + b 变成字符串拼接。

坑 3:Windows 中文乱码

Server 进程默认编码可能是 GBK,导致 docstring 里的中文变乱码,AI 看不懂。
解决:sys.stdout.reconfigure(encoding="utf-8")(本文示例已加)。

坑 4:stdio 通信被污染

Server 里任何 print() 都会写到 stdout,被 Client 当 JSON-RPC 消息解析,直接报错。
解决:调试日志写 sys.stderr 或用 logging

坑 5:同步函数阻塞

FastMCP 默认在事件循环里跑,长耗时同步函数会卡住整个 Server。
解决:用 async def 或把耗时任务丢线程池。


十一、总结

一份 MCP Server 暴露能力给 AI,核心只走 4 步:

  1. 握手 —— initialize,告诉 Client「我有 tools/resources/prompts」
  2. 发现 —— tools/list 等,把每个方法的 name + description + schema 列出来
  3. 注入 —— Client 把这个清单转成 LLM 能理解的 tools 字段
  4. 调用 —— LLM 决定调用 → Client 通过 tools/call 转发 → Server 执行 → 结果回流

AI 知道你的 MCP 有什么方法、怎么调,本质是看你函数的 docstring 和类型注解 —— 这两个东西通过 MCP 的 tools/list 协议化输出,成为 AI 决策的唯一依据。所以写 MCP Server 时,把 docstring 当 API 文档写,把类型注解当契约写,工具质量直接决定 AI 调用质量。


十二、参考

  • 官方 Python SDK:https://github.com/modelcontextprotocol/python-sdk
  • MCP 协议规范:https://modelcontextprotocol.io
  • Inspector 调试工具:npx @modelcontextprotocol/inspector
  • Anthropic MCP 公告:https://www.anthropic.com/news/model-context-protocol

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐