前两篇文章聊了 MCP 协议本身和生态盘点,很多读者私信问同一个问题:“协议我懂了,Demo 也跑通了,但怎么把一个 MCP Server 真正部署到生产环境?”

这是一个非常关键的问题。MCP 协议只定义了"模型怎么调用工具",但生产环境要考虑的东西远不止协议本身:

  • 谁能调用你的工具?——鉴权
  • 调用频率怎么控制?——限流
  • 返回的简历里带着手机号怎么办?——数据脱敏
  • 出问题了怎么排查?——审计日志
  • 多个租户的数据会不会串?——会话隔离
  • 服务怎么上线、扩容、滚动更新?——容器化 + K8s

今年我们在招聘平台落地了企业级 MCP 服务(简历检索、人才画像、职位查询等),把这些能力全部沉淀成了一个开源模板 fastmcp-enterprise。这篇文章就把整个落地过程讲透,所有代码都能直接跑。

一、先看整体架构

企业级 MCP Server 不是"一个 FastMCP 实例",而是一组横切能力 + 业务 Tools 的组合。fastmcp-enterprise 的架构长这样:

D e C p l l a o u y d m e e n C F t o a d s e t M S / C T e P o r C o v u S l i L r M e s D c L s C r ( o e M o P v P c r e I k A r I e g / ) r I e ( n n ( F / g t H a r T s K e A T t 8 s g P M s s e C n / P t S 4 C T . I D x / I ) C O D )

关键点:横切能力通过中间件注入,业务 Tools 保持纯粹。这样新接一个业务工具,不需要改任何鉴权/限流/脱敏代码。

二、FastMCP 4.x 的中间件机制

FastMCP 4.x 引入了钩子式中间件(Middleware),这是构建企业级能力的关键。它不再是"装饰器套装饰器",而是提供了一套生命周期钩子:

  • on_initialize:初始化阶段
  • on_request:每个请求进入时
  • on_message:消息层面
  • on_call_tool:工具被调用时(重点!)
  • on_read_resource / on_get_prompt:资源和提示词

来看一个最简单的审计中间件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from fastmcp.server.middleware import Middleware, MiddlewareContext

class AuditMiddleware(Middleware):
    """记录每次工具调用的审计日志。"""

    async def on_call_tool(self, ctx: MiddlewareContext):
        start = time.time()
        result = await super().on_call_tool(ctx)  # 调用下一个中间件/业务逻辑
        duration_ms = (time.time() - start) * 1000
        # 记录:谁、什么时候、调了什么工具、耗时多久
        self.logger.info(
            f"[audit] client={ctx.client_id} tool={ctx.message.params.name} "
            f"duration={duration_ms:.1f}ms"
        )
        return result

中间件是洋葱模型super().on_call_tool(ctx) 往下走,返回时再处理后置逻辑。鉴权、限流、审计、会话隔离全部按这个模式写,然后 mcp.add_middleware(...) 一层层挂上去。

三、企业级鉴权:Bearer Token

生产环境第一件事就是:不是谁都能调你的工具。FastMCP 4.x 通过 AuthProvider 提供标准化鉴权,我们实现了一个 BearerTokenAuthProvider

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from fastmcp.server.auth import AccessToken
from mcp.server.auth.provider import AuthProvider

class BearerTokenAuthProvider(AuthProvider):
    """基于静态 Token 列表的鉴权(可替换为 JWT / OAuth2)。"""

    def __init__(self, tokens: list[str], scopes: list[str] | None = None):
        self._tokens = set(tokens)
        self.required_scopes = scopes or ["read", "write"]

    async def verify_token(self, token: str) -> AccessToken | None:
        if token in self._tokens:
            return AccessToken(
                token=token,
                client_id=self._derive_client_id(token),  # 稳定标识
                scopes=self.required_scopes,
            )
        return None  # 无效 token → 401

注意一个坑:AccessToken 在 FastMCP 4.x 里必须提供 client_id,否则会抛 Pydantic 校验错误。我们从 token 哈希派生一个稳定 client_id,既满足协议又不暴露原始 token。

四、限流:滑动窗口

生产环境要防滥用。我们实现了一个进程内的滑动窗口限流器,按客户端维度限流:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time
import threading
from collections import deque

class SlidingWindowRateLimiter:
    """滑动窗口限流器:固定窗口 + 细粒度滑动,避免边界突刺。"""

    def __init__(self, max_requests: int, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self._hits: dict[str, deque] = {}   # client_id -> 时间戳队列
        self._lock = threading.Lock()

    def allow(self, client_id: str) -> bool:
        now = time.time()
        with self._lock:
            q = self._hits.setdefault(client_id, deque())
            while q and q[0] < now - self.window_seconds:
                q.popleft()
            if len(q) >= self.max_requests:
                return False
            q.append(now)
            return True

为什么不用固定窗口? 固定窗口有一个经典问题:如果限制 60 次/分钟,用户在 59:5900:01 各发 60 次,实际 2 秒内打了 120 次。滑动窗口让"限流"更平滑。生产环境多实例部署时,把 deque 换成 Redis ZSET 即可(模板预留了 REDIS_ENABLED 开关)。

五、数据脱敏:PII 保护

招聘平台最敏感的是简历里的手机号、身份证、邮箱。LLM 工具调用的响应要过一层脱敏中间件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import re

_PHONE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
_IDCARD = re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)")
_EMAIL = re.compile(r"([\w.+-]+)@([\w-]+\.)+[\w-]+")

def desensitize_value(value: str) -> str:
    """对字符串做 PII 脱敏。"""
    value = _PHONE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-4:], value)
    value = _IDCARD.sub(lambda m: m.group(0)[:6] + "********" + m.group(0)[-4:], value)
    value = _EMAIL.sub(lambda m: m.group(1)[:2] + "***@" + m.group(2), value)
    return value

脱敏中间件放在中间件链的最外层,确保无论业务返回什么,最终给到 LLM 的都是脱敏后的数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class DesensitizeMiddleware(Middleware):
    """响应脱敏中间件:保护手机号/身份证/邮箱等 PII。"""

    async def on_call_tool(self, ctx: MiddlewareContext):
        result = await super().on_call_tool(ctx)
        # 对 text 输出逐条脱敏
        for content in result.content:
            if getattr(content, "type", "") == "text":
                content.text = desensitize_value(content.text)
        return result

效果(真实测试):

: : 1 1 3 3 9 9 1 * 2 * 3 * 4 * 4 4 3 3 2 2 1 1 l l i i n * z * h * u @ a e n x g a @ m e p x l a e m . p c l o e m . c o m 3 3 3 3 0 0 1 1 0 0 6 6 1 * 9 * 9 * 0 * 0 * 1 * 0 * 1 * 1 1 2 2 3 3 4 4

六、会话隔离:多租户不串数据

企业 MCP 服务往往是多租户的——不同客户看到的是自己的简历库。我们在中间件里注入 client_id 到请求上下文,业务工具通过 ctx.request_state 读取当前租户:

1
2
3
4
5
6
7
8
class SessionIsolationMiddleware(Middleware):
    """会话隔离:把 client_id 注入请求上下文,业务侧按租户过滤。"""

    async def on_request(self, ctx: MiddlewareContext):
        request_state = ctx.request_state
        if request_state:
            request_state.client_id = ctx.client_id   # 来自鉴权
        return await super().on_request(ctx)

业务工具侧:

1
2
3
async def resume_search(query: ResumeQuery, ctx: Context) -> dict:
    tenant = ctx.request_state.client_id if ctx.request_state else "default"
    # 只查当前租户的简历数据 ...

一个容易踩的坑:在 FastMCP 4.x 里,函数参数里注入 Context 必须显式标注类型 ctx: Context,否则 FastMCP 会把它当成业务参数,报 Missing required argument

七、业务工具:保持纯粹

有了横切能力,业务工具就只需要关心业务本身。以简历检索为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ResumeQuery(BaseModel):
    keyword: str = Field(description="搜索关键词")
    min_years: int = Field(default=3, description="最低工作年限")
    limit: int = Field(default=10, description="返回条数")

@mcp.tool()
async def resume_search(query: ResumeQuery) -> dict:
    """按关键词和年限检索简历(企业级 MCP 示例工具)。"""
    candidates = demo_db.filter(keyword=query.keyword, min_years=query.min_years)
    return {
        "total": len(candidates),
        "items": [c.model_dump() for c in candidates[: query.limit]],
    }

八、部署:Docker + K8s

Dockerfile(非 root 运行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
RUN pip install -e .
# 非 root 用户,生产安全基线
RUN useradd -m appuser && chown -R appuser /app
USER appuser
EXPOSE 8080
CMD ["python", "-m", "enterprise_mcp.server"]

K8s Deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: apps/v1
kind: Deployment
metadata:
  name: enterprise-mcp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: enterprise-mcp
  template:
    metadata:
      labels:
        app: enterprise-mcp
    spec:
      containers:
        - name: server
          image: your-registry/enterprise-mcp:latest
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: mcp-secrets   # Token 走 Secret 注入
          resources:
            requests: {cpu: "100m", memory: "128Mi"}
            limits: {cpu: "500m", memory: "512Mi"}
          readinessProbe:
            httpGet: {path: /mcp, port: 8080}

CI 自动化

GitHub Actions 里跑测试,自动构建镜像:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.12"}
      - run: pip install -e ".[test]"
      - run: pytest tests/ -v

九、本地验证全流程

模板自带 13 个单元 + 集成测试:

1
2
pytest tests/ -v
# 13 passed

启动 HTTP 服务,用官方 MCP 客户端连接:

1
AUTH_ENABLED=true API_TOKENS=sk-test-001 python -m enterprise_mcp.server
1
2
3
4
5
6
7
8
from fastmcp import Client

async with Client("http://localhost:8080/mcp", auth="sk-test-001") as client:
    tools = await client.list_tools()
    result = await client.call_tool(
        "resume_search",
        {"query": {"keyword": "MCP", "min_years": 5}},
    )
  • 不带 token → 401 拒绝 ✅
  • 带有效 token → 正常调用 ✅
  • 响应脱敏 → 手机号/邮箱被打码 ✅

十、模板结构速览

f a s t m s d t e c r e e x p c p s a - / l t m e e s c a m t o d k s p n n e o u i o y 8 / l t t r n t d o c s e e e v f h d l k / s r r e i / l s e / p p r g e / r r r . / w / i i p s a s s y e r e e t e / _ t / m i c n p g / s . p y # # # # # # # # # D D o e c p T k l + e o e k r y n e f m v n i e / l n e t . + + e + n d S v A o e u / c r t k v h e i P r c r - e o c v o + i m d p I e o n r s g e r e s s

十一、项目地址

完整可运行代码已开源(MIT 协议,欢迎 Star / PR):

小结

从"能跑"到"能上线",MCP Server 还有不少路要走。核心思路是:横切能力中间件化,业务工具纯粹化。鉴权、限流、脱敏、审计、会话隔离这些企业级能力,用 FastMCP 4.x 的钩子中间件可以优雅地组合起来,再加上 Docker/K8s/CI,一个生产级的 MCP 服务就成型了。

如果你正在做企业级 MCP 落地,欢迎在评论区交流踩过的坑。下一篇我会写如何用 Redis 把限流和会话做到多实例共享,以及 JWT 鉴权在 MCP 里的实践,欢迎关注。


延伸阅读


💡 相关推荐:把 MCP Server 部署到生产环境需要稳定的云服务器,可看看腾讯云近期活动:腾讯云活动