资讯动态

从零手写一个生产级 MCP Server:鉴权、流式传输与状态管理(TaoToken 统一 Key 接入版)

发布时间:2026/9/27 14:11:24 来源:尧图企业网站定制
1. 为什么本地跑通的 MCP Server 一上线就出事MCP Server 是 Model Context Protocol 的服务端实现简单说就是给 Claude Code、Cursor、各类 Agent 提供工具调用能力的后台服务。它能把数据库查询、文件操作、内部 API 调用包装成标准工具让模型按 JSON-RPC 2.0 协议调用。适合谁适合已经用官方 SDK 写过 Stdio 版 Demo、现在要把它部署到内网给多个 Agent 共用的后端工程师。我见过太多团队卡在同一个地方本地mcp.run()跑得好好的一放进 Kubernetes 就出三类事故。第一类是跨租户越权A 部门的 Agent 查到了 B 部门的表根因是租户信息存在全局变量里异步并发一交错就串了。第二类是长连接雪崩几十个 Agent 同时建 SSE 连接没有心跳保活也没有超时清理active_sessions字典只增不减内存慢慢被僵尸会话吃光。第三类是异常炸进程某个工具执行时抛了未捕获异常整个 MCP 进程直接退出所有在线会话一起断。这三个问题的共同点是它们都不是协议层的问题而是工程层的问题。官方 Demo 用 Stdio 模式一次只服务一个客户端天然没有并发和隔离的烦恼。但生产环境里 MCP Server 是一个独立微服务必须自己处理鉴权、流式传输和状态管理。这篇就按这三个硬核环节从零搭一个能上线的版本接入通道用 TaoToken 的统一 Key省掉自己维护多套上游凭证的麻烦。2. TaoToken 前置准备统一 Key 与接入通道TaoToken 在这里扮演的角色是统一的上游模型接入层。你的 MCP Server 内部如果要调用模型能力比如工具执行后让模型总结结果不需要在代码里硬编码各家厂商的 Key而是通过 TaoToken 的统一 API 通道转发。这样换模型、加配额、做审计都只在一个地方改。先去官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册然后在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建一个 API Key。这个 Key 就是后面Authorization: Bearer里要带的东西。API 基地址是 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为 base_url 使用。如果你打算长期跑编码类 Agent建议顺手看一下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它针对高频工具调用场景做了配额优化。只是想先验证模型通不通用模型对话 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 页面直接测就行。Key 的管理入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入细节看文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。拿到 Key 之后先写两个配置文件。第一个是config.toml放服务端参数# config.toml [server] host 0.0.0.0 port 8080 sse_ping_interval 15 # 心跳间隔秒 session_ttl 300 # 会话空闲超时秒 tool_timeout 30 # 单次工具执行硬超时秒 [upstream] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY # 从环境变量读不写死在文件里 default_model claude-sonnet-4 [auth] require_tenant_header true jwt_algorithm HS256第二个是settings.json给客户端Claude Code 或你的 Agent 框架用{ mcpServers: { enterprise-mcp: { type: sse, url: http://127.0.0.1:8080/sse, headers: { Authorization: Bearer ${TAOTOKEN_API_KEY}, X-Tenant-ID: team-alpha } } } }注意X-Tenant-ID这个头它是后面多租户隔离的锚点。每个 Agent 实例配自己的租户 ID服务端据此决定它能访问哪些数据。Key 通过环境变量注入不要提交进 Git。3. 可复制配置鉴权中间件与流式响应骨架下面这份代码是完整的可运行骨架基于 Python 3.11 和 FastAPI。核心设计有三个用ContextVar做租户上下文隔离用asyncio.Queue做 SSE 下行通道用asyncio.wait_for做心跳和超时。# enterprise_mcp_server.py import asyncio import json import uuid from contextvars import ContextVar from typing import Any, Dict, List from fastapi import Depends, FastAPI, Header, HTTPException, Request, status from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel app FastAPI(titleEnterprise MCP Server, version1.0.0) # 关键租户上下文用 ContextVar不用全局变量 current_tenant_id: ContextVar[str] ContextVar(current_tenant_id, defaultdefault) class SessionContext: def __init__(self, session_id: str, tenant_id: str): self.session_id session_id self.tenant_id tenant_id self.queue: asyncio.Queue asyncio.Queue() self.last_active asyncio.get_event_loop().time() active_sessions: Dict[str, SessionContext] {} class ToolDefinition(BaseModel): name: str description: str inputSchema: Dict[str, Any] REGISTERED_TOOLS: Dict[str, Any] {} TOOL_SCHEMAS: List[ToolDefinition] [] def mcp_tool(name: str, description: str, schema: Dict[str, Any]): def decorator(fn): REGISTERED_TOOLS[name] fn TOOL_SCHEMAS.append(ToolDefinition(namename, descriptiondescription, inputSchemaschema)) return fn return decorator mcp_tool( namequery_tenant_metrics, descriptionQuery production metrics for the authenticated tenant safely., schema{ type: object, properties: { metric_name: {type: string, description: e.g. qps, error_rate, latency}, time_range: {type: string, description: e.g. 1h, 24h, 7d}, }, required: [metric_name], }, ) async def query_tenant_metrics(metric_name: str, time_range: str 1h) - str: tenant current_tenant_id.get() return json.dumps({ tenant_id: tenant, metric: metric_name, time_range: time_range, data: {avg_value: 142.5, p99_latency_ms: 23.4, status: healthy}, }) async def auth_middleware( x_tenant_id: str Header(..., aliasX-Tenant-ID), authorization: str Header(..., aliasAuthorization), ) - str: if not authorization.startswith(Bearer ) or not x_tenant_id: raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detailUnauthorized: Missing valid Bearer token or Tenant Header, ) current_tenant_id.set(x_tenant_id) return x_tenant_id鉴权中间件做了两件事校验 Bearer 格式把租户 ID 写进ContextVar。这里必须用ContextVar而不是模块级全局变量因为 FastAPI 的异步请求会在同一个事件循环里交错执行全局变量会被后一个请求覆盖导致租户串号。接下来是 SSE 端点和消息端点app.get(/sse) async def sse_endpoint(request: Request, tenant_id: str Depends(auth_middleware)): session_id str(uuid.uuid4()) session_ctx SessionContext(session_idsession_id, tenant_idtenant_id) active_sessions[session_id] session_ctx async def event_generator(): try: yield fevent: endpoint\ndata: /messages?session_id{session_id}\n\n while True: if await request.is_disconnected(): break try: msg await asyncio.wait_for(session_ctx.queue.get(), timeout15.0) yield fevent: message\ndata: {json.dumps(msg)}\n\n except asyncio.TimeoutError: yield : ping\n\n # 心跳防止中间层断连 finally: active_sessions.pop(session_id, None) # 无论怎么退出都清理 return StreamingResponse( event_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, # 关键告诉 NGINX 不要缓冲 }, )X-Accel-Buffering: no这行是踩过坑才加的。NGINX 默认会缓冲响应SSE 事件会被攒在缓冲区里客户端半天收不到一条看起来像连接卡死。加上这个头NGINX 才会逐条透传。消息端点负责接收上行 JSON-RPC 请求把结果塞进对应会话的队列app.post(/messages) async def message_endpoint( request: Request, session_id: str, tenant_id: str Depends(auth_middleware), ): session_ctx active_sessions.get(session_id) if not session_ctx: raise HTTPException(status_code404, detailSession not found or expired) current_tenant_id.set(tenant_id) payload await request.json() method payload.get(method) req_id payload.get(id) if method initialize: await session_ctx.queue.put({ jsonrpc: 2.0, id: req_id, result: { protocolVersion: 2024-11-05, capabilities: {tools: {}}, serverInfo: {name: EnterpriseProductionMCPServer, version: 1.0.0}, }, }) return JSONResponse({status: accepted}) if method tools/list: await session_ctx.queue.put({ jsonrpc: 2.0, id: req_id, result: {tools: [t.model_dump() for t in TOOL_SCHEMAS]}, }) return JSONResponse({status: accepted}) if method tools/call: params payload.get(params, {}) tool_name params.get(name) arguments params.get(arguments, {}) fn REGISTERED_TOOLS.get(tool_name) if fn is None: response {jsonrpc: 2.0, id: req_id, error: {code: -32601, message: fTool {tool_name} not found}} else: try: result await asyncio.wait_for(fn(**arguments), timeout30.0) response {jsonrpc: 2.0, id: req_id, result: {content: [{type: text, text: str(result)}]}} except asyncio.TimeoutError: response {jsonrpc: 2.0, id: req_id, error: {code: -32001, message: Tool execution timeout}} except Exception as e: response {jsonrpc: 2.0, id: req_id, error: {code: -32000, message: fExecution Error: {e}}} await session_ctx.queue.put(response) return JSONResponse({status: accepted}) return JSONResponse({status: ignored})工具执行外面套了asyncio.wait_for30 秒硬超时。没有这层保护一个卡死的 SQL 查询会把整个工作线程池拖垮。异常也被捕获并转成 JSON-RPC 标准错误码进程不会因为单个工具报错而退出。4. 验证请求curl 走通完整链路服务启动后先验证鉴权是否生效。不带任何头直接请求应该返回 401curl -i http://127.0.0.1:8080/sse # HTTP/1.1 422 Unprocessable Entity缺少必填 Header带上正确的头和 Key建立 SSE 连接export TAOTOKEN_API_KEY你的Key curl -N -H Authorization: Bearer ${TAOTOKEN_API_KEY} \ -H X-Tenant-ID: team-alpha \ http://127.0.0.1:8080/sse-N关闭 curl 自己的缓冲你会看到第一行返回event: endpointdata 里带着 session_id。记下这个 ID另开一个终端发上行请求curl -X POST http://127.0.0.1:8080/messages?session_id刚才的ID \ -H Authorization: Bearer ${TAOTOKEN_API_KEY} \ -H X-Tenant-ID: team-alpha \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:tools/list}返回{status:accepted}后切回第一个终端应该能看到 SSE 流里推下来一条event: message内容是工具列表。再发一次tools/callcurl -X POST http://127.0.0.1:8080/messages?session_id刚才的ID \ -H Authorization: Bearer ${TAOTOKEN_API_KEY} \ -H X-Tenant-ID: team-alpha \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:2,method:tools/call,params:{name:query_tenant_metrics,arguments:{metric_name:qps,time_range:1h}}}SSE 流里会推回tenant_id: team-alpha的指标数据。把X-Tenant-ID换成team-beta再建一个新会话返回的tenant_id会跟着变证明租户隔离生效。如果两个会话的数据串了说明ContextVar没用对回去检查是不是写成了全局变量。5. 本篇常见错排查SSE 连上但收不到任何事件。九成是反向代理缓冲。检查响应头有没有X-Accel-Buffering: noNGINX 配置里对应 location 加proxy_buffering off;。另外确认Content-Type是text/event-stream写错成application/json客户端不会按流处理。会话莫名消失报 404 Session not found。看session_ttl和心跳间隔。如果sse_ping_interval大于中间层空闲超时很多网关默认 60 秒连接会被静默掐断但服务端finally已经清理了会话。把心跳设成 15 秒比网关超时短一截。多租户数据串号。最常见的原因是在异步函数里用了模块级全局变量存tenant_id。FastAPI 的请求在同一个事件循环里并发执行全局变量会被覆盖。必须用contextvars.ContextVar它在每个异步任务里有独立副本。工具报错导致整个进程退出。检查tools/call分支有没有把fn(**arguments)包在 try/except 里。任何未捕获异常在异步任务里都可能冒泡到事件循环。同时确认asyncio.wait_for的超时参数生效否则卡死的工具会一直占着协程。401 但头明明带了。检查Authorization的值有没有Bearer前缀加空格Header(..., aliasX-Tenant-ID)的别名大小写要和客户端发的一致。HTTP 头名不区分大小写但 FastAPI 的 alias 匹配是精确的写成x-tenant-id可能匹配不上。连接数上不去新会话建不了。看active_sessions有没有泄漏。正常关闭时finally会 pop但如果客户端异常断网没发关闭信号request.is_disconnected()要等下一次循环才检测到。配合session_ttl做定期扫描清理别只依赖断连检测。6. 下一步把 Key 和通道固定下来这套骨架跑通后鉴权、流式传输、状态管理三个环节就都有了可上线的底子。剩下的是把它接进你真实的工具集数据库连接池按租户路由、工具执行加沙箱、日志接 OpenTelemetry。这些都可以在现有结构上叠加不用推翻重来。接入层建议固定用 TaoToken 的统一 Key别在 MCP Server 代码里散落各家厂商的凭证。Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理接入规范看 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你要跑的是长期在线的编码 AgentCoding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 的配额模型更适合高频工具调用。想先确认模型通道通不通用模型对话 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 发一条测试请求最快。最后提醒一句config.toml里的api_key_env指向环境变量部署时用 K8s Secret 或容器编排的密钥管理注入别把 Key 写进镜像层。这一步做对后面换 Key、轮换凭证都不用改代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑