基于 MCP 协议的工具开发指南

2 阅读6分钟

适用版本:MCP Spec 2025-06-18 / SDK 1.x

目录


一、MCP 协议简介

MCP(Model Context Protocol) 是 Anthropic 提出并开源的开放协议,让 LLM 应用以标准化方式访问外部工具、数据源、上下文。可类比"AI 领域的 USB-C"。

1.1 解决的问题

  • 工具碎片化:每个 LLM 应用各自实现工具调用,无法复用
  • 数据源接入难:每接入一个新数据源都要重写适配
  • 能力组合受限:无法把多个独立工具组合给一个 agent

1.2 关键收益

角色收益
LLM 应用开发者一次接入 MCP,自动获得整个生态的工具
工具/数据源开发者写一次 MCP server,被所有 MCP client 复用
用户在 Claude Desktop/Cursor/IDE 间统一工具体验

二、核心架构

┌─────────────────┐                ┌─────────────────┐
│   MCP Host      │                │   MCP Server    │
│  (Claude / IDE) │                │   (你的工具)    │
│                 │  JSON-RPC 2.0  │                 │
│ ┌─────────────┐ │ ←────────────→ │  Tools          │
│ │ MCP Client  │ │   stdio / HTTP │  Resources      │
│ └─────────────┘ │                │  Prompts        │
└─────────────────┘                └─────────────────┘

2.1 三类角色

角色说明
Host用户面向的 LLM 应用(Claude Desktop、Cursor、IDE 插件)
ClientHost 内的协议客户端,与单个 Server 1:1
Server暴露能力的进程(数据库、文件系统、API 网关等)

2.2 三类能力

能力谁控制适用场景
ToolsLLM模型自行决定调用(写、查询、副作用)
Resources应用应用决定加载哪些(文件、文档、数据)
Prompts用户用户主动选择的模板(slash command)

三、协议消息

3.1 JSON-RPC 2.0 基础

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "query_db",
    "arguments": { "sql": "SELECT * FROM users LIMIT 10" }
  }
}

3.2 关键方法集

方法方向作用
initializeC→S协议握手
notifications/initializedC→S握手完成通知
tools/listC→S枚举工具
tools/callC→S调用工具
resources/listC→S枚举资源
resources/readC→S读取资源
resources/subscribeC→S订阅资源变化
prompts/listC→S枚举提示词模板
prompts/getC→S获取提示词内容
sampling/createMessageS→Cserver 反向请求 LLM 推理
notifications/tools/list_changedS→C工具列表变更

3.3 握手流程

Client                                Server
  │                                     │
  │── initialize (protocolVersion) ────>│
  │<──── result (capabilities) ─────────│
  │── notifications/initialized ───────>│
  │                                     │
  │── tools/list ──────────────────────>│
  │<──── tools[] ───────────────────────│
  │                                     │
  │── tools/call ──────────────────────>│
  │<──── content[] ─────────────────────│

四、Tools / Resources / Prompts

4.1 Tool(工具)

{
  "name": "query_database",
  "title": "Query Database",
  "description": "执行只读 SQL 查询",
  "inputSchema": {
    "type": "object",
    "properties": {
      "sql":   { "type": "string", "description": "SQL 语句" },
      "limit": { "type": "integer", "default": 100 }
    },
    "required": ["sql"]
  },
  "annotations": {
    "readOnlyHint": true,
    "destructiveHint": false,
    "idempotentHint": true
  }
}

annotations 对 LLM 与 host 的安全决策很关键:

字段含义
readOnlyHint不修改环境
destructiveHint可能产生破坏性副作用
idempotentHint多次调用结果一致
openWorldHint与外部世界交互(如网络)

4.2 Resource(资源)

{
  "uri": "file:///project/README.md",
  "name": "README",
  "title": "Project Readme",
  "mimeType": "text/markdown",
  "description": "项目说明文档"
}

URI scheme 自定义:db://git://s3:// 都可以。

4.3 Prompt(提示词模板)

{
  "name": "code_review",
  "title": "Code Review",
  "description": "对指定文件做 Code Review",
  "arguments": [
    { "name": "file_path", "description": "文件路径", "required": true },
    { "name": "focus", "description": "重点关注(perf/security/style)" }
  ]
}

五、Python SDK 开发

pip install "mcp[cli]"

5.1 最简 server(FastMCP)

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("calculator")

@mcp.tool()
def add(a: float, b: float) -> float:
    """两数相加"""
    return a + b

@mcp.tool()
def divide(a: float, b: float) -> float:
    """两数相除"""
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b

if __name__ == "__main__":
    mcp.run()  # 默认 stdio

5.2 资源 + 动态参数

@mcp.resource("file://{path}")
def read_file(path: str) -> str:
    """读取本地文件"""
    with open(path, "r", encoding="utf-8") as f:
        return f.read()

@mcp.resource("config://app")
def app_config() -> str:
    return json.dumps({"version": "1.0", "env": "prod"})

5.3 Prompt 模板

from mcp.server.fastmcp.prompts import base

@mcp.prompt()
def code_review(file_path: str, focus: str = "general") -> list[base.Message]:
    return [
        base.UserMessage(
            f"请对 {file_path} 做 Code Review,重点关注 {focus}。"
        )
    ]

5.4 上下文与日志

from mcp.server.fastmcp import Context

@mcp.tool()
async def long_task(items: list[str], ctx: Context) -> str:
    for i, item in enumerate(items):
        await ctx.info(f"处理 {item}")
        await ctx.report_progress(i, len(items))
        await process(item)
    return f"完成 {len(items)} 项"

5.5 Sampling(server 反向调 LLM)

from mcp.types import SamplingMessage, TextContent

@mcp.tool()
async def summarize_long(text: str, ctx: Context) -> str:
    result = await ctx.session.create_message(
        messages=[SamplingMessage(
            role="user",
            content=TextContent(type="text", text=f"摘要:{text}")
        )],
        max_tokens=500,
    )
    return result.content.text

Sampling 让 server 借用 host 端的 LLM,无需自己持有 API key。

5.6 低层 API(精细控制)

from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

app = Server("low-level-demo")

@app.list_tools()
async def list_tools() -> list[Tool]:
    return [Tool(
        name="echo",
        description="Echo input",
        inputSchema={
            "type": "object",
            "properties": {"text": {"type": "string"}},
            "required": ["text"],
        },
    )]

@app.call_tool()
async def call_tool(name, arguments):
    if name == "echo":
        return [TextContent(type="text", text=arguments["text"])]
    raise ValueError(f"unknown tool: {name}")

async def main():
    async with stdio_server() as (read, write):
        await app.run(read, write, app.create_initialization_options())

六、TypeScript SDK 开发

npm i @modelcontextprotocol/sdk zod

6.1 最简 server

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "calculator", version: "1.0.0" });

server.registerTool(
  "add",
  {
    title: "Add",
    description: "两数相加",
    inputSchema: { a: z.number(), b: z.number() },
    annotations: { readOnlyHint: true, idempotentHint: true },
  },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }],
  }),
);

await server.connect(new StdioServerTransport());

6.2 资源(含动态模板)

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

server.registerResource(
  "file",
  new ResourceTemplate("file://{path}", { list: undefined }),
  { title: "Local File" },
  async (uri, { path }) => {
    const text = await fs.readFile(path as string, "utf-8");
    return { contents: [{ uri: uri.href, text, mimeType: "text/plain" }] };
  },
);

6.3 Prompt

server.registerPrompt(
  "review_pr",
  {
    title: "Review PR",
    description: "审查指定 PR",
    argsSchema: { pr_url: z.string().url(), focus: z.string().optional() },
  },
  ({ pr_url, focus }) => ({
    messages: [{
      role: "user",
      content: { type: "text", text: `请审查 ${pr_url},关注 ${focus ?? "整体"}` },
    }],
  }),
);

6.4 Sampling

server.registerTool(
  "rewrite",
  { description: "用 LLM 重写文本", inputSchema: { text: z.string() } },
  async ({ text }, { sendRequest }) => {
    const result = await sendRequest({
      method: "sampling/createMessage",
      params: {
        messages: [{ role: "user", content: { type: "text", text: `重写:${text}` } }],
        maxTokens: 1000,
      },
    });
    return { content: [{ type: "text", text: result.content.text }] };
  },
);

七、传输层

7.1 stdio(本地工具首选)

  • 进程间管道,host 启动 server 子进程
  • 零配置,启动快
  • 适合:CLI 工具、本地数据访问
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/docs"]
    }
  }
}

7.2 Streamable HTTP(远程 server 推荐)

mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)

特性:

  • 单一 POST /mcp endpoint
  • 服务端可选择返回 JSON 或 SSE 流
  • 支持断线重连(Last-Event-ID 头)
  • 替代了旧的 HTTP+SSE 双端点设计

7.3 SSE(已弃用但仍兼容)

新项目不推荐使用,仅为向后兼容旧 client 保留。

7.4 选择策略

场景选择
本地 CLI / 文件系统stdio
公司内部远程 serverStreamable HTTP
跨网公开 SaaSStreamable HTTP + OAuth
浏览器内 clientStreamable HTTP(必须)

八、Sampling 与 Roots

8.1 Sampling

server 反过来调用 host 的 LLM,避免:

  • server 持有用户的 API key
  • 模型选择不一致
  • 成本不透明

Host 必须显示弹窗征得用户同意(人类在回路)。

8.2 Roots

Client 告诉 server"你能访问哪些目录/URI"。Server 在工具执行时不应越界。

const client = new Client({ name: "my-app", version: "1.0" }, {
  capabilities: {
    roots: { listChanged: true },
  },
});

client.setRequestHandler(ListRootsRequestSchema, async () => ({
  roots: [
    { uri: "file:///workspace/project-a", name: "Project A" },
  ],
}));

Server 端:

roots = await ctx.session.list_roots()
for r in roots.roots:
    if not file_path.startswith(r.uri.path):
        raise PermissionError("path outside roots")

九、客户端集成

9.1 Claude Desktop

~/Library/Application Support/Claude/claude_desktop_config.json(macOS):

{
  "mcpServers": {
    "my-tools": {
      "command": "uv",
      "args": ["run", "python", "/path/to/server.py"]
    }
  }
}

9.2 Claude Code

claude mcp add my-tools -- python /path/to/server.py
claude mcp add --transport http weather https://weather.example.com/mcp
claude mcp list

9.3 Cursor

.cursor/mcp.json

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_TOKEN": "ghp_xxx" }
    }
  }
}

9.4 自建 client(Python)

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(
    command="python", args=["server.py"]
)

async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()
        tools = await session.list_tools()
        result = await session.call_tool("add", {"a": 1, "b": 2})
        print(result.content[0].text)

十、调试与测试

10.1 MCP Inspector(官方调试 UI)

npx @modelcontextprotocol/inspector python server.py
# 浏览器打开 http://localhost:5173

可视化测试 tools/resources/prompts,实时看协议消息。

10.2 单元测试

import pytest
from mcp.shared.memory import create_connected_server_and_client_session
from server import mcp

@pytest.mark.asyncio
async def test_add():
    async with create_connected_server_and_client_session(mcp._mcp_server) as (_, client):
        result = await client.call_tool("add", {"a": 2, "b": 3})
        assert result.content[0].text == "5"

10.3 协议日志

import logging
logging.basicConfig(level=logging.DEBUG)
# 所有 JSON-RPC 消息会打到 stderr

⚠️ stdio 传输下,stdout 必须只输出协议消息,日志一律走 stderr。

10.4 Schema 校验

工具的 inputSchema 必须是合法 JSON Schema,否则 host 拒绝注册。可用 jsonschema 库做本地校验:

from jsonschema import Draft7Validator
Draft7Validator.check_schema(tool.inputSchema)

十一、安全考量

11.1 信任边界

[ Host (用户授权) ] ─ 用户视角的工具调用
        │
        ▼
[ Client ] ─ 协议层校验
        │
        ▼
[ Server (你的代码) ] ─ 真正执行操作
        │
        ▼
[ 系统资源 ] ─ DB/FS/API

每一层都要做纵深防御

11.2 工具风险等级

等级示例建议
安全计算、查询annotations.readOnlyHint=true
中风险写文件、发邮件描述里明确说明副作用
高风险删除、付款、运维destructiveHint=true,host 必须二次确认

11.3 Prompt Injection 防护

工具返回内容会进入 LLM 上下文,任何外部数据都可能藏指令

@mcp.tool()
async def fetch_url(url: str) -> str:
    text = await http_get(url)
    # 不要直接返回原始 HTML:考虑过滤、清洗、加边界标记
    return f"<external_content>\n{sanitize(text)}\n</external_content>"

11.4 路径与资源限制

ALLOWED_ROOTS = ["/workspace", "/tmp/sandbox"]

def validate_path(p: str):
    p = os.path.realpath(p)
    if not any(p.startswith(r) for r in ALLOWED_ROOTS):
        raise PermissionError(f"path outside sandbox: {p}")

11.5 速率限制

from aiolimiter import AsyncLimiter
limiter = AsyncLimiter(10, 1)  # 10 req/s

@mcp.tool()
async def expensive_call(...):
    async with limiter:
        return await call_api(...)

11.6 OAuth(远程 server)

MCP 2025-06-18 规范要求远程 server 用 OAuth 2.1 + PKCE:

from mcp.server.auth import OAuthServerProvider

provider = OAuthServerProvider(
    issuer_url="https://auth.example.com",
    audience="my-mcp-server",
)
mcp = FastMCP("secure-server", auth=provider)

十二、生产部署

12.1 Dockerfile(Streamable HTTP)

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
HEALTHCHECK CMD python -c "import httpx; httpx.get('http://localhost:8080/health').raise_for_status()"
CMD ["python", "-m", "server"]
# server.py
mcp = FastMCP("prod-server")

@mcp.custom_route("/health", methods=["GET"])
async def health(_):
    from starlette.responses import JSONResponse
    return JSONResponse({"status": "ok"})

mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)

12.2 K8s 部署

apiVersion: apps/v1
kind: Deployment
metadata: { name: mcp-server }
spec:
  replicas: 3
  selector: { matchLabels: { app: mcp } }
  template:
    metadata: { labels: { app: mcp } }
    spec:
      containers:
        - name: mcp
          image: registry/mcp-server:1.0
          ports: [{ containerPort: 8080 }]
          envFrom: [{ secretRef: { name: mcp-secrets } }]
          readinessProbe:
            httpGet: { path: /health, port: 8080 }
          resources:
            limits: { cpu: "1", memory: "1Gi" }

12.3 横向扩展注意

  • stdio server 无法横向扩展:1 client = 1 进程
  • HTTP server 默认无状态:可任意扩缩容
  • 若使用 sampling/订阅等长连接特性,需 sticky session

12.4 配置管理

import os
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    db_url: str
    api_key: str
    log_level: str = "INFO"

    class Config:
        env_file = ".env"

settings = Settings()

十三、常见模式

13.1 工具组合

把多个工具拆细 + 组合,比单个"巨型工具"更适合 LLM 调用:

# 好:拆成原子操作
@mcp.tool() def list_tables() -> list[str]: ...
@mcp.tool() def describe_table(name: str) -> dict: ...
@mcp.tool() def query(sql: str) -> list[dict]: ...

# 差:一个大杂烩
@mcp.tool() def database_op(op: str, **kwargs): ...

13.2 资源 vs 工具

  • 数据本身 → Resource(让 host 决定什么时候加载)
  • 执行操作 → Tool(让 LLM 决定何时调用)

举例:

需求用法
读 READMEResource file://README.md
搜索代码Tool search_code(query)
列出 issues都行,但 Resource 更利于上下文常驻

13.3 进度回报

长任务必须用 ctx.report_progress,否则 host 端无反馈:

@mcp.tool()
async def index_repo(path: str, ctx: Context) -> str:
    files = list_files(path)
    for i, f in enumerate(files):
        await ctx.report_progress(i, len(files))
        await index_one(f)
    return f"已索引 {len(files)} 文件"

13.4 错误返回

工具内部错误不应抛异常导致协议中断,而应作为内容返回 + isError

@mcp.tool()
async def query(sql: str) -> list[TextContent]:
    try:
        return [TextContent(type="text", text=run(sql))]
    except SQLError as e:
        return [TextContent(type="text", text=f"SQL error: {e}", isError=True)]

十四、常见问题

14.1 Claude Desktop 看不到 server

  • 确认配置文件路径与平台一致
  • 重启 Claude Desktop(配置变更不自动 reload)
  • 看日志:macOS ~/Library/Logs/Claude/mcp-server-*.log

14.2 stdio server 启动后立即退出

  • stdout 被业务日志污染 → 改走 stderr 或 logging 文件
  • print(...) 默认走 stdout,不要直接 print

14.3 Tool 不被 LLM 调用

  • description 太模糊 → 写明用途、输入输出示例
  • 工具名不直观 → 用 verb_noun 风格(search_docscreate_issue
  • inputSchema 太复杂 → 拆成多个简单工具

14.4 大对象返回慢

  • 返回 Resource URI 让 host 按需读取,而不是 Tool 内嵌大文本
  • 流式返回(多次 ctx.info + 最后一次 result)

14.5 类型 vs Schema 不匹配

Python SDK 自动从类型注解生成 schema,但复杂类型(嵌套 TypedDict)可能失败,可手动写:

@mcp.tool()
def search(filter: dict) -> list[dict]:
    """..."""
    ...

# 手写 schema:
search.input_schema = {
    "type": "object",
    "properties": {
        "filter": {
            "type": "object",
            "properties": {
                "tag":  {"type": "string"},
                "date": {"type": "string", "format": "date"},
            },
        },
    },
    "required": ["filter"],
}

十五、完整示例:数据库 MCP Server

一个生产级只读 SQL 查询 server:

import asyncio
import asyncpg
import os
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP, Context

DB_URL = os.environ["DATABASE_URL"]
ALLOWED_SCHEMAS = {"public", "analytics"}
MAX_ROWS = 1000

pool: asyncpg.Pool | None = None

@asynccontextmanager
async def lifespan(server):
    global pool
    pool = await asyncpg.create_pool(DB_URL, min_size=1, max_size=5)
    yield
    await pool.close()

mcp = FastMCP("postgres-readonly", lifespan=lifespan)

def is_safe_sql(sql: str) -> bool:
    s = sql.strip().lower()
    forbidden = ("insert", "update", "delete", "drop", "alter", "truncate", "grant")
    return s.startswith("select") and not any(w in s for w in forbidden)

@mcp.tool(
    annotations={"readOnlyHint": True, "idempotentHint": True}
)
async def query(sql: str, ctx: Context) -> str:
    """执行只读 SELECT 查询。返回 JSON 数组。"""
    if not is_safe_sql(sql):
        return "ERROR: 仅允许 SELECT 查询"

    sql = f"SELECT * FROM ({sql}) AS subq LIMIT {MAX_ROWS}"
    await ctx.info(f"执行:{sql}")

    async with pool.acquire() as conn:
        rows = await conn.fetch(sql)
        return str([dict(r) for r in rows])

@mcp.tool(annotations={"readOnlyHint": True})
async def list_tables() -> list[str]:
    """列出所有可访问的表"""
    async with pool.acquire() as conn:
        rows = await conn.fetch("""
            SELECT table_schema || '.' || table_name AS qname
            FROM information_schema.tables
            WHERE table_schema = ANY($1::text[])
            ORDER BY 1
        """, list(ALLOWED_SCHEMAS))
    return [r["qname"] for r in rows]

@mcp.tool(annotations={"readOnlyHint": True})
async def describe_table(name: str) -> dict:
    """查看表结构"""
    schema, table = name.split(".", 1) if "." in name else ("public", name)
    if schema not in ALLOWED_SCHEMAS:
        return {"error": f"schema {schema} 不允许"}
    async with pool.acquire() as conn:
        rows = await conn.fetch("""
            SELECT column_name, data_type, is_nullable
            FROM information_schema.columns
            WHERE table_schema=$1 AND table_name=$2
        """, schema, table)
    return {"columns": [dict(r) for r in rows]}

@mcp.resource("schema://{table}")
async def get_schema(table: str) -> str:
    info = await describe_table(table)
    return str(info)

@mcp.prompt()
def explore_schema() -> str:
    return ("先调用 list_tables 看看有哪些表,"
            "再用 describe_table 选几个相关的查看结构,"
            "最后写 query 提取数据。")

if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)

15.1 客户端配置(Claude Code)

claude mcp add --transport http db https://db-mcp.internal:8080/mcp \
  --header "Authorization: Bearer $TOKEN"

15.2 一次完整的对话

用户:我们最活跃的用户是谁?
LLM ─> list_tables()
LLM ─> describe_table("public.users")
LLM ─> describe_table("public.events")
LLM ─> query("SELECT u.name, COUNT(e.id) FROM users u
              JOIN events e ON e.user_id = u.id
              GROUP BY u.name ORDER BY 2 DESC LIMIT 5")
LLM <─ [{"name":"Alice","count":1234},...]
LLM:最活跃用户是 Alice,共 1234 次事件...

附录:速查

A.1 Annotations 快查

字段含义
readOnlyHint只读
destructiveHint破坏性
idempotentHint幂等
openWorldHint涉外(网络)

A.2 SDK 速查

功能PythonTypeScript
注册工具@mcp.tool()server.registerTool
注册资源@mcp.resource(uri)server.registerResource
注册 Prompt@mcp.prompt()server.registerPrompt
进度上报ctx.report_progressextra.sendNotification
反向 samplingctx.session.create_messagesendRequest
启动 stdiomcp.run()server.connect(StdioServerTransport)
启动 HTTPmcp.run("streamable-http")StreamableHTTPServerTransport

A.3 调试入口

工具用途
@modelcontextprotocol/inspector协议调试 UI
mcp dev server.py内置 dev server
Claude Desktop logs~/Library/Logs/Claude/
stderrserver 端日志输出位置

A.4 资源链接