RSS

第 7 章 / 共 12 章

让 server 拥有状态:生命周期、密钥与并发

约 9 分钟 更新于

7.1 每次调用都重连一次数据库,会发生什么

到这里,你的工具都是无状态的:调用进来,读文件,返回。真实的 server 很少这么单纯——它要连数据库、要维护一个索引、要拿 API 密钥、要复用 HTTP 连接池。

如果不管,你会很自然地写成这样:

@mcp.tool()
def search_notes(keyword: str) -> str:
    """搜索笔记。"""
    index = build_index(NOTES_DIR)   # 每次调用都重建一次索引
    return index.search(keyword)

问题在模型的行为模式上:模型不像人,它常常连着调三五次工具来收敛一个答案。每次重建索引,就是把这笔开销乘以五。数据库连接更糟——连接建立本身有延迟,宿主那边体现出来就是「这个工具好慢」,慢到一定程度会触发超时。

正确的位置是生命周期:进程启动时准备一次,所有调用共享,进程退出时清理。

第7章:让 server 拥有状态:生命周期、密钥与并发
图 7.1:状态分三层,各有各的存放位置。放错层的代价分别是「慢」「泄露」和「串号」。

7.2 lifespan:启动准备一次,退出清理一次

SDK 用一个异步上下文管理器表达这件事,yield 之前是启动,之后是关闭:

import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("notes")

@dataclass
class AppContext:
    notes_dir: Path
    index: dict[str, str]

@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
    notes_dir = Path(os.environ.get("NOTES_DIR", "notes")).expanduser().resolve()
    log.info("building index from %s", notes_dir)
    index = {p.name: p.read_text(encoding="utf-8") for p in notes_dir.glob("*.md")}
    try:
        yield AppContext(notes_dir=notes_dir, index=index)
    finally:
        log.info("shutting down, dropping index of %d notes", len(index))

mcp = MCPServer("notes", version="0.2.0", lifespan=app_lifespan)

工具通过上下文对象取回它:

@mcp.tool()
async def count_notes(ctx: Context[AppContext]) -> str:
    """统计笔记库里有多少篇笔记。"""
    app = ctx.request_context.lifespan_context
    return f"{len(app.index)} 篇笔记,目录 {app.notes_dir}"

三个要注意的点:

  • 上下文靠类型注解注入,不靠参数名。 参数叫 ctx 还是 context 还是 c 都行,关键是标注了 Context 类型。(旧版本的 get_context() 函数已经没有了。)
  • 写成 Context[AppContext] 能拿到类型提示,lifespan_context 会被正确推断成 AppContext。
  • 清理逻辑放在 finally 里,否则异常退出时连接不会被关。

这段代码实际跑起来的日志(stderr)是:

INFO:notes:building index from /path/to/notes
INFO:notes:shutting down, dropping index of 2 notes

启动一次,退出一次,中间的所有调用共享同一份索引。

7.3 密钥从哪里来:你的 shell 环境不在那里

这是接入阶段最常见的一个「本地好好的,装到宿主里就不行」。

原因是:server 是被宿主作为子进程启动的,它继承的是宿主的环境,不是你终端的环境。 你在 .zshrc 里 export 的 NOTES_API_KEY,在 Claude Desktop 启动的子进程里根本不存在。

所以密钥要通过宿主配置显式传进去。各客户端的写法第 9 章会讲,共同点是配置里有一个 env 字段。开发期也可以用 SDK 的安装命令把变量写进配置:

uv run mcp install server.py -v NOTES_DIR=/abs/path/notes -f .env

三条纪律:

  • 代码里只从环境变量读,不要硬编码,也不要读用户主目录下某个约定文件。
  • 给一个安全的默认值,让没配置的人也能跑起来(比如 NOTES_DIR 默认取当前目录下的 notes)。
  • 密钥绝不能出现在工具的返回值里。 返回值会进模型上下文,可能被记录、被转发、被展示。错误信息也一样——第 6.4 节讲的异常遮蔽机制,正是为了防这件事。

7.4 同步函数会跑在工作线程上:三个副作用

这是 SDK 2.x 的一个行为变化,写有状态 server 时特别容易踩。

旧版本里,同步的 def handler 直接在事件循环上执行,一旦阻塞就卡死整个 server。新版本改了:同步 handler 会被丢到工作线程里执行。这解决了阻塞问题,但带来三个新的副作用:

  1. 同步 handler 里调用 asyncio.get_running_loop() 会抛 RuntimeError——那个线程上没有事件循环。
  2. 线程亲和的状态会出问题:thread-local 变量、非线程安全的对象(很多数据库驱动的连接对象就是),现在会被不同的工作线程碰到。
  3. 同步 handler 之间会并发执行。你不能再假设它们是串行的,共享的可变状态需要自己加锁。

判断规则很简单:要用事件循环上的东西(异步客户端、共享的异步连接池),就把函数写成 async def;纯 CPU 或纯阻塞 IO 的短任务,写成 def 让它去工作线程,反而更好。

7.5 跨调用的状态:用 handle,不要用会话

最后一类状态:一次调用产生的东西,下一次调用要用。比如「开始一次批量整理,先返回预览,用户确认后再执行」。

在旧的有状态协议里,你可能会想把它挂在会话上。现在不行了——2026-07-28 移除了协议级会话,服务器不该假设两个请求属于同一个会话。规范给出的替代方案很明确:需要跨调用状态的服务器,使用由服务器自己生成的显式 handle,作为普通的工具参数传递。

落到代码上就是这个形状:

@mcp.tool()
def plan_cleanup(pattern: str, ctx: Context[AppContext]) -> CleanupPlan:
    """预览一次批量整理会改动哪些笔记,返回一个计划 id。不会真的修改文件。"""
    plan_id = secrets.token_urlsafe(16)
    ctx.request_context.lifespan_context.plans[plan_id] = compute_changes(pattern)
    return CleanupPlan(plan_id=plan_id, will_change=[...])

@mcp.tool()
def apply_cleanup(plan_id: str, ctx: Context[AppContext]) -> str:
    """执行之前用 plan_cleanup 生成的整理计划。"""
    plan = ctx.request_context.lifespan_context.plans.pop(plan_id, None)
    if plan is None:
        raise ToolError("这个计划 id 不存在或已过期,请先调用 plan_cleanup 重新生成。")
    ...

plan_id 是普通的工具参数,模型能看见、能传递,服务器端把它当查表的键。这个模式还有一个附带好处:它天然形成了「先预览、再执行」的两步确认,对有副作用的工具来说,这本身就是一道安全设计。

有一条纪律要写进第 11 章的清单:handle 不是身份凭据。 规范对此有硬性要求——绝不能把「持有某个状态 handle」当作认证通过。它只能用来定位数据,不能用来判断「这个人有权做这件事」。

7.6 练习与检查点