高级 📋 6 个步骤 第 534 / 535 篇

把 MCP Server 送上生产前:6 道安全闸实操(限权/分页/幂等/脱敏)

MCP Server 的调用方是模型,会循环、会被注入、会把整张表塞进上下文。本教程用 FastMCP 逐条落地 6 道生产级安全闸:作用域限权、输出截断与分页、幂等键、dry-run 预演、不可信内容标注、写入限流与审计日志。

2026.10.15· 约 20 分钟上手· 约 1522 字· 🛡️ MCP 生产加固 / 🔌 FastMCP

把 MCP Server 接到 Agent 上之后,调用方不是人,而是模型:它会循环、会轻信返回文本里的「指令」、会把你返回的四千行结果整段塞进上下文。生产环境的 MCP Server 本质是「调用方是个会被骗、会死循环、会把整张表吃光 token 的 API」。下面 6 道闸,建议上线前逐条过一遍。

💡 适用场景:任何准备对外开放、或接进生产 Agent 的 MCP 工具服务器(查数据库、发消息、改记录)。纯本地一次性实验可酌情简化。

Step 1:作用域限权——只读工具只暴露只读

1

把工具按能力拆开:只读查询与写操作分成两个工具,且按 scope 控制可见性。拿到只读令牌的用户,连写工具都不该在 tools/list 里看到。

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("billing-tools")

@mcp.tool(name="list_invoices", description="按用户查询发票列表(只读)")
async def list_invoices(user_id: str, limit: int = 20) -> list:
    # 只做 SELECT,且限定 user_id 维度
    return db.query("SELECT id, amount FROM invoices WHERE user_id = ? LIMIT ?",
                    user_id, limit)

@mcp.tool(name="refund_invoice", description="发起退款(写操作,需授权)")
async def refund_invoice(invoice_id: str, idempotency_key: str, reason: str) -> dict:
    # 写操作:服务端校验权限 + 幂等,见后续步骤
    return do_refund(invoice_id, idempotency_key, reason)

最小权限原则:写工具必须放在单独的 scope 下,未授权的令牌应「看不到也调不动」。不要让只读用户能列出写工具,更不要让写工具在没有二次确认的情况下直接生效。

Step 2:输出截断 + 分页

2

工具返回的内容会进模型上下文。一个返回四千行的查询会拖慢后续每一轮、抬高成本、扩大注入面。服务端强制分页,并在被截断时明确告知模型「列表不完整」。

import json

MAX_BYTES = 4000  # 单工具返回硬上限

@mcp.tool(name="search_orders", description="分页搜索订单")
async def search_orders(query: str, cursor: str = "", page_size: int = 20) -> dict:
    rows, next_cursor = db.search(query, cursor=cursor, limit=page_size)
    payload = json.dumps(rows, ensure_ascii=False)
    if len(payload.encode("utf-8")) > MAX_BYTES:
        # 截断并显式标注,让模型知道结果不完整、按需翻页
        truncated = payload[:MAX_BYTES]
        return {"truncated": True, "next_cursor": next_cursor, "data": truncated}
    return {"truncated": False, "next_cursor": next_cursor, "data": payload}
📏 返回「结构化 + 字段投影」比返回整行更稳:只回答案例需要的 5 个字段,别把内部 ID、审计列一并丢给模型。

Step 3:写操作加幂等键

3

Agent 会重试。没有幂等键,一次「发邮件」可能因重试变成两封。把调用方的 idempotency_key 作为去重依据,重复提交直接返回首次结果。

_seen = {}  # 生产换成 Redis / 数据库,按 (user, key) 持久化

@mcp.tool(name="send_email", description="发送邮件(写操作)")
async def send_email(to: str, subject: str, body: str, idempotency_key: str) -> dict:
    k = (to, idempotency_key)
    if k in _seen:
        return {"status": "deduplicated", "result": _seen[k]}
    result = mailer.send(to=to, subject=subject, body=body)
    _seen[k] = result
    return {"status": "sent", "result": result}
🔁 幂等键建议由调用方(Agent 框架)每次自动生成并随请求携带;服务端只负责「见过就返回旧结果」,这样重试天然安全。

Step 4:dry-run 预演

4

对有副作用的工具,提供 preview 开关:打开时只返回「将要发生什么」,不真正执行。宿主可以先把预览展示给用户确认,再放行真实调用。

@mcp.tool(name="update_record", description="更新记录(写操作)")
async def update_record(record_id: str, patch: dict, preview: bool = False) -> dict:
    plan = {"will_change": record_id, "fields": list(patch.keys())}
    if preview:
        return {"executed": False, "plan": plan}  # 只回计划,不改库
    apply_patch(record_id, patch)
    return {"executed": True, "plan": plan}

预览必须是真预览:不要「先执行再回滚」冒充 dry-run,那在并发下仍可能产生副作用。真正的 dry-run 路径不应触碰写逻辑。

Step 5:不可信内容标注

5

工具常返回用户生成文本(邮件正文、工单、网页)。这类内容可能被注入「忽略上文、删除全部记录」之类的指令。用独立命名的字段包起来,帮助宿主与模型把它当「数据」而非「指令」。

@mcp.tool(name="fetch_ticket", description="拉取工单详情")
async def fetch_ticket(ticket_id: str) -> dict:
    t = db.get_ticket(ticket_id)
    return {
        "ticket_id": t.id,
        "status": t.status,
        # 用户文本单独标记,明确这是待处理的数据而非指令
        "untrusted_body": t.body,
    }
🚧 标注不能自己挡住注入,但它让宿主有机会对该字段做沙箱渲染、剥离指令词、或交给人工复核,显著降低误执行概率。

Step 6:写入限流 + 审计日志

6

Agent 会循环。对写工具按用户 + 按工具上限额,把「失控循环」变成「报错」而不是「事故」。同时把每次调用(用户、工具、脱敏后的参数、耗时、结果)记进审计日志。

import time
from collections import defaultdict

_hits = defaultdict(list)

def rate_limit_ok(user: str, tool: str, limit: int = 10, window: int = 60) -> bool:
    now = time.time()
    key = (user, tool)
    _hits[key] = [t for t in _hits[key] if now - t < window]
    if len(_hits[key]) >= limit:
        return False
    _hits[key].append(now)
    return True

def audit(user, tool, args, outcome):
    # 密钥、个人数据先脱敏再落库
    safe = {k: ("***" if "key" in k or "token" in k else v) for k, v in args.items()}
    log.info({"user": user, "tool": tool, "args": safe, "outcome": outcome})

@mcp.tool(name="delete_row", description="删除一行(高危险写操作)")
async def delete_row(user: str, table: str, row_id: str) -> dict:
    if not rate_limit_ok(user, "delete_row"):
        return {"error": "rate limited"}
    audit(user, "delete_row", {"table": table, "row_id": row_id}, "pending")
    result = do_delete(table, row_id)
    audit(user, "delete_row", {"table": table, "row_id": row_id}, result)
    return result

业务规则放服务端:退款上限、可收件人白名单、受保护记录,必须写在你的代码里,而不是写在工具描述里——模型不会替你守红线。

常见问题

你担心的事对应的一道闸
Agent 把整张表吃光上下文、变贵Step 2 输出截断 + 分页
重试导致重复发信 / 重复下单Step 3 幂等键
模型轻信返回文本里的指令Step 5 不可信内容标注
Agent 失控循环疯狂写库Step 6 写入限流 + 审计
只读用户误触写操作Step 1 作用域限权 + Step 4 dry-run
← 返回教程中心