自建 MCP server 第一周完美第二周崩塌的 4 天复盘:跨用户 auth 泄漏 + cancel 不生效 + 资源竞争三连击根因 + contextvars/two-step confirm 工程纪律落地

MCP server 是 2026 年 AI 工程化的关键基础设施, 但生产化门槛比看起来高得多。本文复盘内部 MCP server 第一周 demo 完美、第二周开放 30 人立刻崩塌的 4 天定位过程: 全局变量保存 user 导致跨用户串号 + httpx 不响应 asyncio cancel 导致僵尸调用 + 共享 asyncpg pool 慢查询拖死快查询三类问题叠加, 最终用 contextvars + cancel_event + per-tool pool + rate limit + two-step confirm 的组合方案修复, 日均调用量从 1200 涨到 9800。

2026 年 4 月一个周一上午,我们内部的 MCP server 上线两周后第一次接到"很奇怪"的反馈:研发同事 A 在 Claude Code 里跑 查一下我负责的工单,结果返回的是同事 B 名下的工单列表;另一位前端同事则报告"刚才点了停止,但 MCP server 那边还在跑,过了 30 秒才停"。MCP(Model Context Protocol)是 Anthropic 2024 年底放出来的标准协议,我们 2026 年初决定自建一套 MCP server,让公司所有研发的 Claude Code / Cursor 都能调用内部知识库、工单系统、Git 仓库元数据——本质上是把"AI 助手"从"只懂公开知识"升级成"懂我们公司"。第一周只我一个人用,完美。第二周开放给 30 人,问题立刻冒出来。

接下来 4 天我们带着平台组把 MCP 协议 + 自己的 server 实现彻底过了一遍,定位到三类相互独立但叠加发作的问题:跨用户的 session 状态泄漏(用户认证 token 被错误共享到全局)、MCP 的 cancellation 没正确实现(client 取消但 server 端继续跑)、tool 调用的并发竞争(共享的 DB 连接池被某些长查询占满)。每一个单独看都是后端服务的"老问题",但 MCP 协议本身的某些设计让它们更难发现。这篇是完整复盘,涵盖 MCP 协议的核心机制、3 类生产问题的根因、修复方案与最佳实践,以及落地的《MCP server 生产纪律》。

背景:我们的 MCP server 在做什么

维度 数值
用途 公司内部 MCP server,服务全员 Claude Code / Cursor / 其他 MCP client
提供的 tool 13 个:查工单 / 查 user / 查 Git 提交 / 查 PR / 查内部 wiki / 触发 CI / 等
技术栈 Python 3.12 + MCP Python SDK 0.10 + FastAPI + PostgreSQL
部署 内网 K8s,2 Pod(高可用),通过 stdio / http+SSE 两种传输
用户规模 研发 30+ 人,Claude Code / Cursor 用户
事故现象 跨用户数据串号 + cancel 不生效 + tool 调用偶发超时

MCP 协议本身很新,2024 年底发布,2025 年中各大 AI Coder 才陆续支持。2026 年我们做自建 MCP 时,文档和最佳实践都还在快速迭代。我们参考的是 Anthropic 官方文档 + Python SDK 的示例代码——这次踩的坑,很大程度上是因为示例代码"不为生产环境设计"。

事故时间线:从串号反馈到根因落地的 4 天

时刻 事件
04-13 09:30 同事 A 反馈 MCP 返回的工单是别人的,我开始排查
04-13 上午 对照 MCP server 的日志,发现确实给同事 A 的 query 返回了同事 B 的数据
04-13 下午 定位到根因 1:auth context 被存到全局变量,后到的请求覆盖了先到的
04-14 修第一个 bug 同时,另一个同事报告"取消 MCP 调用后 server 还在跑"
04-14 下午 研究 MCP 协议的 cancellation 机制,发现 server SDK 没有完整支持
04-15 发现第三个问题:某个 tool 用了共享 DB 连接池,多用户高并发时被占满
04-16 上午 设计完整修复方案:per-request context + 显式 cancel handler + 资源隔离
04-16 下午 预发跑多用户并发测试,通过
04-17 上线 + 写《MCP server 生产纪律》

三类问题叠加的因果链

这张图最关键的信息是三类问题虽然独立但在多用户场景下相互放大:auth 泄漏让用户看到错误数据 / cancel 失效让资源浪费累积 / 资源竞争让快查询被慢查询拖死。任何一个单独存在都不至于让我们 4 天复盘,叠加在一起的体感就是"这个 MCP server 完全不能用"。这也是为什么 single-user dev 环境完美 production 立刻崩——多用户并发是放大器,把所有隐藏 bug 全部显形。我们后来内部把"MCP server 第一周开放给 30+ 用户的崩塌"作为团队 onboarding 教材,讲"sample code 不是 production code"的活案例。

真凶 1:跨用户 auth context 泄漏

这是最严重的问题。我们 MCP server 最初是这样实现的:

from mcp.server import Server
from mcp.types import Tool

server = Server('internal-tools')
current_user = None        # ❌ 全局变量!

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list:
    global current_user
    user_id = arguments.get('user_id')
    if not user_id:
        raise ValueError('user_id required')

    # "存"当前用户上下文
    current_user = await load_user(user_id)

    if name == 'list_my_tickets':
        return await query_tickets(current_user)
    # ... 其他 tool

async def query_tickets(user):
    rows = await db.fetch('SELECT * FROM tickets WHERE assignee_id = $1', user.id)
    return rows

这段代码在单用户场景下没问题,但 30 个并发用户时灾难——current_user 是模块级全局变量,所有协程共享同一个。请求 A 设置 current_user=A 后,在 query_tickets 完成前请求 B 进来,设置 current_user=B,等 query_tickets(current_user) 真正执行时,current_user 已经是 B 了。结果就是 A 的请求返回 B 的数据。

这个反模式在传统 web framework(FastAPI / Flask)里大家都警惕——大家都知道用 request.state 或 contextvars,绝不用全局变量。但 MCP Python SDK 的示例代码使用了一些看起来像"全局状态"的写法,我们模仿了,踩到坑。

修法:用 contextvars + per-request session

from contextvars import ContextVar

# 用 ContextVar 而不是 global var
_current_user: ContextVar[User | None] = ContextVar('current_user', default=None)

@server.call_tool()
async def call_tool(name: str, arguments: dict, ctx: ToolContext) -> list:
    # 关键: ctx 是 per-request 的, 不是全局共享
    user_id = ctx.session_metadata.get('user_id')
    if not user_id:
        raise ValueError('user_id required')

    user = await load_user(user_id)
    token = _current_user.set(user)
    try:
        if name == 'list_my_tickets':
            return await query_tickets()
        # ...
    finally:
        _current_user.reset(token)

async def query_tickets():
    user = _current_user.get()
    if user is None:
        raise RuntimeError('user context not set')
    rows = await db.fetch('SELECT * FROM tickets WHERE assignee_id = $1', user.id)
    return rows

contextvars 在 asyncio 协程间是隔离的——每个协程有自己的 context。这意味着不同请求的 _current_user 互不干扰,即使并发跑也是各自的值。

更彻底的做法是把 user 作为参数显式传递,不依赖任何隐式状态。我们最后采纳了这个方案:

@server.call_tool()
async def call_tool(name: str, arguments: dict, ctx: ToolContext) -> list:
    user = await authenticate(ctx)        # 从 ctx 拿 user
    return await dispatch(name, arguments, user)

async def list_my_tickets(user: User) -> list:
    return await db.fetch('SELECT * FROM tickets WHERE assignee_id = $1', user.id)

"显式参数"比"隐式 context"更不易出错——任何函数想用 user 都必须收作参数,改起来 audit 更直接。

真凶 2:MCP cancellation 没正确实现

MCP 协议支持 cancellation——client 可以发 $/cancelRequest 通知 server 取消某个进行中的 tool call。我们的 Python SDK 0.10 版本接收到 cancel notification 后,会通过 asyncio.CancelledError 通知正在跑的协程。理论上协程响应 CancelledError 退出,资源释放。

问题是我们的 tool 实现里有外部调用没传 cancel signal:

async def search_wiki(query: str) -> list:
    # ❌ 这个 httpx 调用不响应 cancel
    response = await httpx_client.post(
        'https://wiki.internal/api/search',
        json={'q': query},
        timeout=60.0
    )
    return response.json()

当 MCP client 发 cancel 时,asyncio 会向当前协程注入 CancelledError——但这个错误只会在协程下一次 await时被抛出。如果当前正卡在 httpx 的响应等待(HTTP 请求已发出,server 慢慢算),cancel 信号会在 await response 那一刻生效——但 httpx 内部的 receive loop 不知道要 abort 底层 socket,会继续等到 server 响应。

结果:client 显示 "cancelled",但 server 端 wiki API 继续跑;wiki API 那边的负载没有减轻,白白浪费资源。如果用户狂取消,server 端积累的"僵尸调用"还会越来越多。

修法:显式传递 cancel signal 到所有外部调用

async def search_wiki(query: str, cancel_event: asyncio.Event) -> list:
    # 用 asyncio.wait_for + httpx CancelToken 等价物
    try:
        async with httpx.AsyncClient() as client:
            req = client.build_request('POST', 'https://wiki.internal/api/search', json={'q': query})
            # 用 stream 模式 + 主动检查 cancel
            async with client.send(req, stream=True) as resp:
                content = b''
                async for chunk in resp.aiter_bytes():
                    if cancel_event.is_set():
                        raise asyncio.CancelledError()
                    content += chunk
                return json.loads(content)
    except asyncio.CancelledError:
        # 关键: 通过 httpx connection close 强制中断 server 侧请求
        # (httpx 1.0+ 的 client 在 close 时会主动 RST 连接)
        raise

更通用的做法是用 asyncio.shield 的反向——给每个 tool 调用绑一个 cancel-aware wrapper:

async def run_tool_with_cancel(tool_fn, *args, request_id: str) -> Any:
    cancel_event = asyncio.Event()
    # 注册到全局 cancel registry
    _cancel_registry[request_id] = cancel_event

    try:
        return await tool_fn(*args, cancel_event=cancel_event)
    finally:
        _cancel_registry.pop(request_id, None)

# MCP cancellation handler
async def handle_cancel(request_id: str):
    event = _cancel_registry.get(request_id)
    if event:
        event.set()    # 通知正在跑的 tool

这套机制让 tool 内部主动检查 cancel_event,在每个 await 点之间检查。配合 httpx 的 stream 模式,可以做到"client 取消 → tool 中断 → 底层 HTTP 连接 RST → 外部 API 收到客户端断开"的完整传播。

真凶 3:tool 调用的资源竞争

第三个问题更隐蔽。我们的 MCP server 有一些 tool 会查公司内部数据库(工单 / wiki),共享一个 asyncpg 连接池:

pool = await asyncpg.create_pool(dsn, min_size=5, max_size=20)

20 个连接对 2 个 MCP server Pod 来说看起来够用——平均每个 Pod 10 个,30 用户最坏情况下也就 1 个用户用一个连接。但实际生产里我们看到 tool 调用偶发卡住几秒——pool 显示 0 个空闲连接。

排查发现两类问题:

  1. 某个 wiki 搜索 tool 触发一个慢查询(全文搜索 + JOIN 没优化好),单次 5-10 秒占用一个连接
  2. 某些 tool 在内部串行调多个查询,每次借用一个连接,加起来占用时间长

30 个用户同时用,即使每个用户只发一个 tool call,如果几个用户碰巧打在慢查询上,连接池就被打满,其他用户的快查询也得排队。MCP client 看到的是"我这个简单查询为什么卡了 5 秒",体验差。

修法:per-tool 连接池 + 超时

# 区分"快"和"慢"两个 pool
fast_pool = await asyncpg.create_pool(dsn, min_size=5, max_size=15,
                                      command_timeout=2.0)   # 快查询: 上限 2s
slow_pool = await asyncpg.create_pool(dsn, min_size=2, max_size=5,
                                      command_timeout=15.0)  # 慢查询: 上限 15s

@server.call_tool()
async def call_tool(name: str, args: dict, ctx: ToolContext):
    user = await authenticate(ctx)
    if name in FAST_TOOLS:
        async with fast_pool.acquire() as conn:
            return await dispatch_fast(name, args, user, conn)
    elif name in SLOW_TOOLS:
        async with slow_pool.acquire() as conn:
            return await dispatch_slow(name, args, user, conn)

把"快"和"慢"物理隔离,慢 tool 最多占 5 个连接,快 tool 始终有 15 个可用。同时给慢 tool 加 15s 上限,防止某个 tool 永远占着连接。

另外加了一层 per-user 的 rate limit:

from collections import defaultdict
from time import monotonic

_user_calls: dict[str, list[float]] = defaultdict(list)

def rate_limit_check(user_id: str, max_per_minute: int = 60):
    now = monotonic()
    calls = _user_calls[user_id]
    # 清理 60 秒前的记录
    calls[:] = [t for t in calls if now - t < 60]
    if len(calls) >= max_per_minute:
        raise ToolError('Rate limit: 60 calls/min per user')
    calls.append(now)

限制单用户每分钟 60 次调用——防止某个用户的 Claude Code agent 进入死循环(参考 028 那篇 LangGraph 文章),把 MCP server 也打挂。

决策树:新加一个 MCP tool 该怎么设计

这棵决策树后来嵌进了我们 MCP server 的 contribution guide:任何新 tool 的 PR 必须在 description 里说清楚走的是哪条分支、对应的 pool / timeout / rate limit / confirm 配置。一个小改动让团队对 MCP tool 的设计直觉提升一个量级——以前是"先实现功能再考虑生产化",现在是"设计阶段就把生产化纳入"。code review 也因此变得更结构化,新人加 tool 的 PR 一次通过率从 30% 提升到 75%。

4 天里被否决的方案

方案 看似可行 否决理由
每个用户起独立的 MCP server 进程 完全隔离, 不用考虑并发 30 用户 = 30 个进程,资源浪费;且无法共享 cache;运维复杂度爆炸
全部 tool 改成同步顺序执行 一次只服务一个用户 简单粗暴, 避免并发问题 用户体验灾难, 排队等几分钟;且不解决根因, 只是延迟暴露
放弃自建 MCP, 直接用 Claude Code 内置 tool 不踩任何 MCP 坑 失去内部数据访问能力, 这是我们做 MCP 的核心价值;且未来路径被锁死
用 process-per-request 模型 (fork worker) 进程级隔离, 没有共享状态问题 asyncpg / httpx 等异步库重新连接代价大;且 cold start 慢, 影响 P99
升级到 MCP SDK 0.11 等官方修 等官方解决 0.11 还在 beta, 且查 changelog 主要是 transport 优化, 不解决业务侧的状态管理;不能等
所有 tool 都加超长 timeout (60s) 不会被超时打断, 简化错误处理 慢 tool 会更长时间占用连接, 反而加剧资源竞争;且用户等待体验变差

每条否决都让我们更清楚"真正要修什么"。最后选定的"contextvars + cancel_event + per-tool pool + rate limit + two-step confirm"组合既是技术最优,也是组织成本最低——所有改动都在 MCP server 核心层,业务侧 tool 改动很小。后来 CTO 在 review 会上问"为啥不直接每个用户一个进程一劳永逸",我们直接甩这张表 3 分钟说服全场。这种"否决记录"在长期维护中比"选定方案"价值还大。

验证:多用户并发测试

预发环境我们设计了多用户并发测试:

测试场景 修复前 修复后
30 用户并发 list_my_tickets 3 个用户拿到错误数据 0 错误
用户取消后 server 资源 仍在跑 30 秒 200ms 内停止
同时 5 个慢查询 + 25 个快查询 快查询平均 8 秒 快查询 200ms,慢查询 单独 pool 不影响
恶意 agent 死循环 100 QPS 整个 MCP server 卡死 该用户被限流,其他用户正常

顺手做的几件事

1. 每个 tool 调用的审计日志

MCP server 必须留完整审计——谁、什么时候、调用了什么 tool、参数、结果。这既是合规需求,也是排查问题的关键证据:

@server.call_tool()
async def call_tool(name: str, args: dict, ctx: ToolContext):
    request_id = str(uuid4())
    audit_log.info({
        'event': 'mcp_tool_call_start',
        'request_id': request_id,
        'user_id': ctx.session_metadata.get('user_id'),
        'tool_name': name,
        'args': sanitize(args),  # 脱敏后记录
        'timestamp': datetime.utcnow().isoformat(),
    })
    try:
        result = await dispatch(name, args, ctx)
        audit_log.info({
            'event': 'mcp_tool_call_success',
            'request_id': request_id,
            'duration_ms': (...),
        })
        return result
    except Exception as e:
        audit_log.error({
            'event': 'mcp_tool_call_error',
            'request_id': request_id,
            'error': str(e),
        })
        raise

2. tool 描述里的 authority hint

MCP 的 tool 定义可以包含描述。我们在每个 tool 描述里加了"权限提示",让 LLM 在调用时知道这个 tool 能做什么:

Tool(
    name='trigger_ci',
    description=(
        'Trigger a CI run for a specific branch. '
        'CAUTION: This consumes CI minutes and can affect production deployment. '
        'You should ALWAYS confirm with the user before calling this tool.'
    ),
    inputSchema={...},
)

实测下来,Claude / GPT 看到这种"CAUTION"提示后,会在调用前主动问用户确认。这不是硬性保护,但减少了"agent 自主决策造成的意外操作"。

3. tool 调用的双因素 + 审批

对"破坏性"tool(触发部署、删除资源、批量操作),我们加了二次确认机制:

@server.call_tool()
async def call_tool(name: str, args: dict, ctx: ToolContext):
    if name in DESTRUCTIVE_TOOLS:
        confirm_token = args.get('confirm_token')
        if not confirm_token:
            # 不是直接执行,返回一个确认 token
            token = create_confirm_token(name, args)
            return {
                'requires_confirmation': True,
                'token': token,
                'message': f'This action requires explicit confirmation. Re-call with confirm_token={token}',
            }
        # 验证 token,确认有效再执行
        validate_token(confirm_token, name, args)
    return await dispatch(name, args, ctx)

LLM 第一次调用会收到"需要确认"的响应,会向用户展示;用户确认后 LLM 再带 confirm_token 调用一次。这种 two-step 让 destructive 动作不会被 agent 一次性执行。

立的《MCP server 生产纪律》

  • 禁止用模块级全局变量保存用户 / 请求状态,统一用 contextvars 或显式参数传递。
  • 所有 tool 实现必须支持 cancellation,await 点之间主动检查 cancel signal。外部调用用 stream 模式,确保能 RST 底层连接。
  • tool 调用必须 per-tool 资源隔离:快 / 慢 tool 用不同连接池,防止互相影响。
  • 必须有 per-user rate limit(默认 60 QPM),防止恶意或失控的 agent 打挂 server。
  • 所有 tool 必须有 timeout,默认 5 秒,慢 tool 上限 30 秒。
  • 所有 tool 调用必须留审计日志:user_id + tool + args + result + duration。
  • 破坏性 tool 必须 two-step confirm,第一次返回 token,第二次带 token 执行。
  • tool 的 description 必须包含权限和后果提示,引导 LLM 谨慎调用。
  • 认证必须基于 client 提供的凭证(API key / session token / OAuth),不允许"通用 service account"模式。
  • MCP server 必须有健康检查 + 监控:tool 调用 QPS / 错误率 / P99 延迟 / 资源池水位。

给读者的几条自查清单

  1. 如果你在做自建 MCP server,先 grep 全局变量(global \w+ 或模块级可变变量),任何"存当前请求状态"的全局都是潜在 bug。
  2. 测试一下多用户并发:开 2 个 Claude Code 实例,同时调用涉及"当前用户"的 tool,看返回的数据对不对。这是最基本的隔离测试。
  3. 测试 cancel:在 Claude Code 里调一个慢 tool,中途 Esc 取消,看 MCP server 日志是不是真的停了。
  4. 看你的 tool 实现里有没有同步阻塞调用(time.sleep / 同步 requests / 同步 IO),这些都不能正确响应 cancel。
  5. 看资源池配置:DB 连接、外部 API client、Redis pool,是不是所有 tool 共享一个?如果是,慢 tool 会拖死快 tool。
  6. 所有 destructive tool(写操作、删除、触发 CI),都加 two-step confirm 或者干脆要求用户在 client 里二次输入。
  7. 审计日志:模拟"一个员工被恶意 agent 操纵滥用 MCP",你的日志能不能还原他每一步操作?

这次踩坑让我对"MCP server 是基础设施"有了更清晰的认知:MCP 不是普通后端 API,它给 LLM 提供"决策权"——LLM 通过 MCP 影响真实世界(部署、改数据、操作资源)。这种"放权"必须配套严格的工程纪律。安全、隔离、可审计、可取消、可限流,这些在传统 web 后端是好实践,在 MCP server 里是必备项

另一个心得:"示例代码"在新协议早期容易过度简化。MCP Python SDK 的官方示例展示的是"协议怎么用",不是"生产怎么部署"——它们不处理多用户、不处理并发、不处理 cancel,因为这些和"展示协议"无关。但很多团队(包括我们)直接 fork 示例当成生产基线,踩坑必然。新协议的 production-ready 经验需要时间沉淀,我们这篇文章就是其中一份。

这次复盘的长期收益

维度 修复前 修复后 60 天
跨用户串号事件 第二周 8 次 0 次
cancel 平均生效时间 20-30 秒(经常不生效) 200ms 内
tool 调用 P99 耗时 偶发 8 秒卡顿 稳定在 500ms
MCP server 周均故障 3-5 次需重启 0 次
研发对内部 MCP 的信任度(内部调研) 2.1 / 5(很多人弃用) 4.5 / 5
日均 MCP tool 调用量 1200 次(用户主动避免) 9800 次(放心使用)
触发的安全 / 合规事件 2 次(误访问他人数据) 0 次

日均调用量从 1200 涨到 9800 这一项最有说服力——以前研发用着用着发现"返回的数据是别人的"就弃用了,信任崩塌后再恢复非常难。修完后大家敢用了,MCP 才真正成为团队提效工具。这种"工具修好了,使用量自发涨 8 倍"的链路,在内部工具领域是最直接的价值证明。

认知更新:对 MCP 工程化的 4 个新认知

  1. MCP server 不是普通 API 服务,它是"给 LLM 用的特权后端"。普通 API 的调用者是确定性程序, MCP 的调用者是 LLM agent——它可能死循环、可能误判、可能被 prompt injection 操纵。所以 MCP server 的防御性必须比普通 API 高一档:rate limit / two-step confirm / audit log / sandbox 全套上。把 MCP 当普通 REST 服务部署是早期最常见的认知误区。
  2. "sample code" 和 "production code" 之间的距离比传统协议大得多。MCP Python SDK 的官方示例展示"协议怎么用",省略了所有生产化关注点。HTTP / gRPC 等成熟协议大家都知道要补哪些(认证、限流、监控),但 MCP 太新, 团队不知道要补什么。结果就是"拿示例当生产基线 → 上线即崩"。新协议早期, 把官方示例当 demo 而不是脚手架,是必须建立的工程纪律。
  3. "asyncio cancellation 真的能 cancel"是个需要主动维护的承诺。Python 的 asyncio 提供 CancelledError 机制,但能不能"真的取消"取决于代码里每个 await 点之间是否主动让出 / 是否在外部调用上传 cancel signal。大量第三方库(httpx / asyncpg / aioredis)有自己的内部 buffer / connection state, 默认不响应 cancel。MCP server 必须 audit 所有 await 链, 确保 cancel 真的能传播到底。这是个长期持续的工程, 不是一次性配置。
  4. "内部基础设施"的口碑由前两周决定。我们 MCP server 第一周 demo 完美, 第二周开放 30 人立刻崩, 第三周虽然修复了但有 8 个用户"再也不用了"——他们后续半年都没回来。内部工具的信任建立比外部产品还难, 因为同事是被"强行 onboard 的", 一次糟糕体验就永远 anchor 在他们心里。新内部工具上线前必须做严格的多用户压测, 第一印象比功能完整度还重要。

这次踩坑让我对"MCP server 是基础设施"有了更清晰的认知:MCP 不是普通后端 API,它给 LLM 提供"决策权"——LLM 通过 MCP 影响真实世界(部署、改数据、操作资源)。这种"放权"必须配套严格的工程纪律。安全、隔离、可审计、可取消、可限流,这些在传统 web 后端是好实践,在 MCP server 里是必备项

另一个心得:"示例代码"在新协议早期容易过度简化。MCP Python SDK 的官方示例展示的是"协议怎么用",不是"生产怎么部署"——它们不处理多用户、不处理并发、不处理 cancel,因为这些和"展示协议"无关。但很多团队(包括我们)直接 fork 示例当成生产基线,踩坑必然。新协议的 production-ready 经验需要时间沉淀,我们这篇文章就是其中一份。希望读到这里的同行能跳过我们走的弯路, 也欢迎在评论区分享你们的 MCP 生产经验, 一起把这块新基础设施的最佳实践沉淀出来。

第三个心得是关于"AI 工具的 blast radius"。传统后端 bug 影响范围相对可控——一个 API 错了,影响调用它的几个 client。MCP server bug 的影响范围更大——它直接喂给 LLM, LLM 再基于错误数据做决策、生成代码、触发动作, 错误会被指数放大。比如这次的跨用户串号, 如果某个用户在 Claude Code 里基于"错误的工单列表"生成了 commit message 或者修了代码, 这个污染会扩散到代码库, 后续 review 都未必能发现。MCP server 的 bug 不只是"返回错数据", 是"污染 AI 的决策链"。所以 MCP 的测试覆盖必须比传统 API 更严, 端到端 + 多用户 + 并发 + 故障注入全套上。我们后来在 CI 里专门建了一套 MCP-specific 的 e2e 测试套件, 模拟 30 用户并发 + 随机 cancel + 慢查询注入, 每次 commit 都跑。这套测试半年下来挡掉了 4 次类似的并发 bug, 投资回报极高。

第四个心得:"修这个 MCP 问题"和"修这类 MCP 问题"是两件事。我们后来主动扫了公司内部所有用到 MCP 的项目(包括三方 MCP server / 我们自己的 / 实验性的), 用同样的"contextvars + cancel + per-tool pool"checklist 过了一遍, 挖出 5 个有类似隐患的服务。一次复盘的真正价值不是修当下, 是把同类问题在它们爆雷前都摸出来。这种"主动扫雷"耗时大约是修一个 bug 的 3 倍, 但避免 5 次类似事故——ROI 极其划算。我们后来在平台组设了固定流程, 每次 P1 事故复盘后必须做"同类扫雷", 半年下来主动避免了 7 次潜在的生产事故。

最后再补一个文化层面的反思:这次事故触发前其实有 2 次小信号——第一周内部 alpha 测试时有同事偶发反馈"奇怪 我刚才看到的数据不对", 大家以为是 bug 没追下去;Python SDK 0.10 升级时有人提过 "怎么没有内置的多用户处理", 当时回答"先用着 后面再说"。所有大事故都有它的"预热信号", 区别只在团队有没有把它当回事。我们后来在 SRE 团队加了"小信号月度复盘"机制——把过去 30 天所有低优先级 bug 报告 + 模糊的用户抱怨 + 新人提的"为什么这样"问题集中拉一遍, 挑出可能升级成事故的提前修。半年下来这个机制至少提前避免了 3 次类似量级的并发问题, 希望读者也能在自己团队建立类似的"小信号雷达"。

如果你正打算自建 MCP server,希望这篇能帮你跳过我们走过的弯路。MCP 是 2026 年 AI 工程化的关键基础设施之一,但它的"生产化"门槛比看起来高得多。这篇文章里的所有 contextvars / cancel_event / per-tool pool / rate limit / two-step confirm 模式都已经在我们生产环境跑了 60 天验证过, 可以直接抄走。如果你在自家 MCP server 上做了类似的优化, 欢迎在评论区分享你的事故时间线、修复代码、踩到的额外坑——MCP 生产化这块, 中文社区甚至全球社区沉淀的实战经验都还稀缺, 每一份数据都是后来者的灯塔。愿读到这里的你能把我们 4 天踩坑的代价省下来, 用在更有创造性的事情上, 把 MCP 真正打造成团队的 AI 协作基础设施, 而不是一个"用着用着就不敢用"的负资产。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Fastify TypeScript billing-api 中 Zod schema 校验占 P99 80ms 中 25ms 的 5 天深度优化:precompile + valibot 混合 + 选择性校验 + safeParse 全过程

2026-5-26 12:20:07

技术教程

Pandas 50GB ETL 跑 240 分钟+月月 OOM 的 2 年挣扎:6 天 Polars 重写压到 11 分钟+1.2GB 内存全过程 + 7 个迁移坑 + 选型决策树

2026-5-26 12:31:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索