状态MCP服务器的高可用性
MCP(模型上下文协议)服务器本质上是有状态的——每个客户端-服务器会话都维护与 mcp-session-id 头球此项目演示了如何使用 HAProxy的木棍桌 为MCP的流式HTTP传输提供会话仿射路由的功能,具有 Redis支持的会话状态 以确保服务器重启和故障转移的持久性。
建筑
graph TD
A[MCP Clients] --> B[HAProxy
:8080 proxy / :8404 stats
sticky sessions + redispatch]
B --> C[mcp-server-1
:8000]
B --> D[mcp-server-2
:8000]
B --> E[mcp-server-3
:8000]
C --> F[Redis 7
session state]
D --> F
E --> F
style B fill:#e1f5ff
style F fill:#ffe1e1- 3个MCP服务器实例 运行有状态的FastMCP服务器(会话范围的计数器和注释)
- 瑞迪斯 对于外部化会话状态--在服务器重启后仍能存活并启用故障转移
- HAProxy 在前面,使用木棍桌绘制地图
mcp-session-id从头部到后端,带有option redispatch用于后端停机时的故障转移 - 全部通过精心策划 Docker Compose
故障恢复流程
当后端崩溃或停止时,由于Redis中的外部化状态,系统会优雅地恢复:
sequenceDiagram
participant Client
participant HAProxy
participant Server2 as mcp-server-2
participant Server1 as mcp-server-1
participant Redis
Note over Client,Redis: 1. Normal Operation: Session pinned to mcp-server-2 via stick-table
Client->>HAProxy: increment_counter
[session: abc123]
HAProxy->>Server2: [stick lookup: abc123→srv-2]
Server2->>Redis: GET mcp:session:abc123:counter
Redis-->>Server2: "4"
Server2->>Redis: SET mcp:session:abc123:counter "5"
Server2-->>HAProxy: counter=5
HAProxy-->>Client: counter=5
Note over Server2: 2. Backend Failure: mcp-server-2 crashes
Server2-xServer2: X (crashed)
Note over HAProxy,Server2: health checks fail × 3
→ mark DOWN
Note over Client,Redis: 3. Client Re-initializes: Connection lost, start new session
Client->>HAProxy: initialize
[no session ID]
Note over HAProxy: [leastconn] pick healthy
HAProxy->>Server1: initialize
Server1-->>HAProxy: [session: xyz789]
Note over HAProxy: [store xyz789→srv-1]
HAProxy-->>Client: session: xyz789
Note over Client,Redis: 4. State Recovery: Copy old session state via resume_session
Client->>HAProxy: resume_session
[session: xyz789]
[old: abc123]
HAProxy->>Server1: resume_session
Server1->>Redis: SCAN mcp:session:abc123:*
Redis-->>Server1: [counter, notes]
Server1->>Redis: GET+SET each key → mcp:session:xyz789:*
Redis-->>Server1: OK (2 keys copied)
Server1-->>HAProxy: keys_copied: 2
HAProxy-->>Client: keys_copied: 2
Note over Client,Redis: 5. Resumed: Continue with recovered state
Client->>HAProxy: increment_counter
[session: xyz789]
HAProxy->>Server1: [stick lookup: xyz789→srv-1]
Server1->>Redis: GET mcp:session:xyz789:counter
Redis-->>Server1: "5"
Server1->>Redis: SET mcp:session:xyz789:counter "6"
Server1-->>HAProxy: counter=6
HAProxy-->>Client: counter=6 (continues!)关键恢复步骤:
- 正常运行:粘桌路线
abc123→mcp-server-2,Redis中的状态 - 检测到故障:健康检查失败(3×5s),HAProxy标记
mcp-server-2下来 - 重新调度:客户端重新初始化,
leastconn选择健康mcp-server-1,新会议xyz789 - 国家恢复:
resume_session(abc123)将所有Redis密钥从旧会话复制到新会话 - 延续:计数器增量5→6,应用程序无缝继续
要点:
option redispatch防止503错误,自动重新路由到健康的后端- Redis中仍存在旧会话状态(30分钟TTL)
resume_session副本状态:abc123:* → xyz789:*- 零数据丢失,只有短暂的连接中断
粘性路由是如何工作的
钥匙在里面 haproxy/haproxy.cfg:
backend mcp_servers
balance leastconn
stick-table type string len 64 size 100k expire 30m
# First request (no session ID): leastconn picks the backend with fewest active connections.
# Learn the session ID from the response header and bind it to this backend.
stick store-response res.hdr(mcp-session-id)
# Subsequent requests: match session ID from request header → same backend.
stick match req.hdr(mcp-session-id)- 第一个请求 (
initialize):客户端没有会话ID。HAProxy路由到活动连接最少的后端(负载感知)。后端以mcp-session-id头球HAProxy存储映射:session-id → backend. - 后续请求:客户端发送
mcp-session-id在请求标头中。HAProxy在stick表中查找它,并将其路由到相同的后端。 - 后端故障:与
option redispatch启用后,HAProxy会重新路由到健康的后端,而不是返回503。会话状态保存在Redis中,可以通过以下方式恢复resume_session.
先决条件
快速开始
1.启动堆栈
docker compose up -d --build这将构建3个MCP服务器映像,并将其与HAProxy一起启动。
2.验证容器是否正在运行
docker compose ps您应该看到5个容器: redis, mcp-server-1, mcp-server-2, mcp-server-3,以及 haproxy.
3.检查HAProxy统计信息
打开 http://localhost:8404/stats 在您的浏览器中。您应该看到所有3个后端的状态 上 (绿色)。
4.检查健康端点
curl http://localhost:8080/health预期输出(后端会有所不同):
{"status":"ok","instance":"mcp-server-1"}健康端点还检查Redis连接。如果Redis无法访问,则返回 503 随着 "status":"degraded".
使用curl进行手动验证
初始化会话
# Send initialize request and capture response headers
curl -s -D - http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "curl-test", "version": "1.0.0"}
}
}'注意 mcp-session-id 响应中的标题。复制它以备后续请求。
测试粘性
使用上面的会话ID,调用 increment_counter 多次:
SESSION_ID="
"
# First increment → counter: 1
curl -s http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": {"name": "increment_counter", "arguments": {}}
}'
# Second increment → counter: 2 (same instance!)
curl -s http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION_ID" \
-d '{
"jsonrpc": "2.0", "id": 3,
"method": "tools/call",
"params": {"name": "increment_counter", "arguments": {}}
}'如果粘性有效,计数器将按顺序递增 instance 字段在调用之间保持不变。
自动化测试
提供了三个互补的测试套件:
1.基础设施测试(基于HTTP)
使用原始HTTP调用测试HAProxy负载平衡、粘性会话和故障转移:
uv sync
uv run python test_lb.py预期产量:
MCP Load Balancing Tests
==================================================
Target: http://localhost:8080/mcp
HAProxy health: 200
=== Test: Sticky Sessions ===
Session ID: d528c59430d14bd1be7f711f910926ff
Routed to: mcp-server-3
Counter reached 10 on mcp-server-3 - stickiness confirmed!
PASSED
=== Test: Distribution Across Backends ===
Sessions distributed across: {'mcp-server-2', 'mcp-server-1', 'mcp-server-3'}
PASSED
=== Test: Session State Isolation ===
Session state is properly isolated
PASSED
=== Test: Health Endpoint ===
Health OK from mcp-server-2
PASSED
=== Test: get_status Tool ===
Instance: mcp-server-1, uptime: 60.4s, sessions: 7
PASSED
=== Test: resume_session Same ID ===
Correctly returned same_session for own session ID
PASSED
=== Test: Notes CRUD ===
Notes after add: ['first note', 'second note', 'third note']
PASSED
=== Test: analyze_data with Notifications ===
Message notifications: 7
Log levels seen: {'info', 'debug'}
Result: items=3, score=9.0
Session summary confirms analysis stored
PASSED
=== Test: Session Summary Resource ===
Summary: counter=3, notes=['hello', 'world'], instance=mcp-server-3
PASSED
=== Test: watch_counter with Notifications ===
Message notifications: 5
Change detection messages: 3
Result: 3 change(s) detected
PASSED
=== Test: Backend Failure - State Recovery ===
Session 381eb66a18a4... on: mcp-server-1
State before crash: counter=3, notes=['survive-crash']
Stopping mcp-server-1...
New session db123608fb3b... on: mcp-server-3
Resumed 2 keys from old session
Counter continues: 4 (state fully recovered)
Restarting mcp-server-1...
PASSED
==================================================
All tests passed!测试验证了什么
| 测试 | 它证明了什么 |
|---|---|
| 粘性会话 | 计数器在同一后端递增1-10。会话ID始终路由到同一服务器。 |
| 分布 | 10个独立会话分布在3个后端中的至少2个(leastconn按活动连接分布)。 |
| 会话状态隔离 | 两个并发会话具有独立的计数器和注释。 |
| 健康端点 | GET /health 返回200 status: ok 以及实例ID;还检查Redis连接。 |
| 获取状态 | 工具返回实例、正常运行时间、活动会话计数和时间戳。 |
| resume_session相同ID | 呼叫 resume_session 返回当前会话自己的ID {"status": "same_session"}. |
| 注意CRUD | 具有多个注释的完整添加/列表循环;验证插入顺序。 |
| 带通知的analyze_data | SSE流包含信息和调试级别日志通知以及最终结果;结果存储在会话中,并可通过资源端点读取。 |
| 会话摘要资源 | resources/read 上 resource://session/{id}/summary 返回正确的计数器、注释和实例。 |
| 带通知的watch_counter | 监视器检测并发计数器增量(后台线程);更改通知出现在SSE流中。 |
| 后端故障-状态恢复 | 当粘性后端停止时,HAProxy会重新发布; resume_session 将Redis密钥复制到新会话,计数器从停止的地方继续 |
2.MCP协议测试(FastMCP客户端)
使用FastMCP客户端库测试MCP工具、通知和资源:
uv run python test_mcp_client.py这些测试侧重于:
- 工具响应和返回值
- 通知流(进度、日志)
- 资源阅读
- 会话恢复逻辑
- 并发操作
主要区别: FastMCP客户端测试验证MCP协议功能,而无需测试基础设施问题(负载平衡、粘性会话、故障转移)。它们通过确保MCP服务器实现正确来补充基于HTTP的测试。
3.弹性测试(弹性客户端)
测试自动会话恢复与实际容器重启:
uv run python test_resilience.py该测试:
- 建立会话状态(计数器、笔记)
- 重新启动所有MCP服务器容器
- 自动重新连接并恢复会话
- 验证Redis中持久的状态是否已恢复
- 无缝地继续运营
实施: 用途 ResilientClient (resilient_client.py),一个围绕FastMCP客户端的包装器,它自动:
- 检测连接失败
- 带回退的退货
- 呼叫
resume_session从Redis恢复状态 - 透明地恢复运营
弹性客户端
服务器端基础架构(HAProxy+Redis)处理路由和状态持久性,但 客户还必须参与恢复当后端崩溃时,MCP协议会话丢失——客户端出现连接错误,而不是透明的故障转移。如果没有客户端逻辑,调用者需要手动重新初始化、调用 resume_session,然后重试失败的操作。
resilient_client.py 提供 ResilientClient,FastMCP周围的一个小包装 Client 这缩小了这一差距:
from resilient_client import ResilientClient
async with ResilientClient("http://localhost:8080/mcp") as client:
result = await client.call_tool("increment_counter", {})
notes = await client.list_resources()
# All async methods automatically retry on failure with session resumption它是如何工作的:
- 来自内部FastMCP的所有异步方法
Client(例如。call_tool,list_resources,read_resource)通过以下方式用重试逻辑包装__getattr__ - 失败时:等待回退,重新连接以获取新会话,调用
resume_session从Redis中的旧会话复制状态 - 在新连接上重试原始呼叫——呼叫者永远看不到中断
没有 ResilientClient,后端故障要求调用者:
- 捕捉连接错误
- 创建新
Client并重新初始化 - 呼叫
resume_session(old_session_id)恢复状态 - 重试失败的操作
这是一种模式 test_lb.py 使用(带有手动恢复的原始HTTP),而 test_mcp_client.py 和 test_resilience.py 使用 ResilientClient 用于自动恢复。
将此应用于您自己的MCP服务器
此参考实现旨在被拆分。下面是您需要采用的六种模式,每种模式都很重要,以及在哪里可以找到代码。
1.将会话状态外部化到共享存储
为什么? 进程中状态随服务器一起死亡。外部化到Redis(或任何网络存储)可以让任何实例在故障转移后为任何会话提供服务——没有它,崩溃意味着完全失去状态。
采用什么:
SessionStoreABC (session_store.py:8-30)--定义存储合同:get,set,delete,keys,copy_session,ping。为您自己的后备存储实现此接口。Session包装器 (session_store.py:33-55)--使用自动JSON序列绑定存储+会话ID+TTL。工具作者只调用session.get(key)/session.set(key, value)--不需要直接从商店进口。RedisSessionStore(stores/redis_store.py)--唯一导入的文件redis.asyncio.按键按mcp:session:{session_id}:{key}带有滑动TTL的图案。InMemorySessionStore(stores/memory_store.py)--dict支持的延迟TTL到期回退,适用于没有Redis的本地开发。- 店铺选择 (
server.py:21) —RedisSessionStore(url) if REDIS_URL else InMemorySessionStore()一个env-var在生产和本地开发之间切换。
2.添加健康检查端点
为什么? 负载均衡器需要检测后端何时关闭,以便停止向其路由流量并触发重新补丁。如果没有健康检查,客户端就会陷入死后端,直到TCP超时到期。
采用什么:
/health端点 (server.py:209-218)--通话store.ping()以验证Redis连接。退货200随着{"status": "ok"}当健康时,503随着{"status": "degraded"}当商店无法到达时。- HAProxy每5秒进行一次民意调查(
haproxy/haproxy.cfg:42-43):option httpchk GET /health随着inter 5s fall 3 rise 2在每一条服务器线上。
3.在工具响应中包含实例标识
为什么? 在跨多个实例调试路由问题时,您需要知道哪个服务器处理了每个请求。没有这个,会话关联性错误是不可见的。
采用什么:
INSTANCE_ID(server.py:16) —os.environ.get("INSTANCE_ID", "unknown")- 每个工具响应包括
"instance": INSTANCE_ID(例如。,server.py:39,server.py:47,server.py:61).这是一个调试辅助工具——如果你愿意,可以在生产环境中去掉它。
4.实施会话恢复工具
为什么? MCP会话ID是短暂的——当后端崩溃时,协议会话丢失,客户端在重新连接时获得新的会话ID。如果没有恢复机制,所有先前的状态都会在旧会话密钥下的存储中孤立。
采用什么:
resume_session工具 (server.py:80-95)--接受旧会话ID,并通过以下方式将所有存储密钥复制到新会话session.copy_from(old_session_id).Session.copy_from()(session_store.py:54-55)--代表们store.copy_session().RedisSessionStore.copy_session()(stores/redis_store.py:39-62)--用途SCAN找到所有旧钥匙并重新-SET它们在新的会话前缀下使用新的TTL。
5.为MCP的流式HTTP配置负载均衡器
为什么? MCP使用SSE进行流式响应 mcp-session-id 会话关联性的标头。默认负载平衡器设置(短超时、无会话粘性)将破坏这两个设置。
从中领养什么 haproxy/haproxy.cfg:
- 粘性会话 (第32、36、40行):
stick-table已接通mcp-session-id+stick store-response res.hdr(mcp-session-id)从第一个响应中学习会话ID+stick match req.hdr(mcp-session-id)以路由后续请求。 - SSE兼容超时 (第14-16行):
timeout client/server 300s,timeout tunnel 600s默认超时(约30s)将在中途终止长时间运行的工具调用。 - 故障转移 (第10行):
option redispatch--当粘性后端关闭时,重新路由到健康的后端,而不是返回503。 - 健康检查 (第42-43行、第45-47行):
option httpchk GET /health随着inter 5s fall 3 rise 2--后端在连续3次失败后标记为DOWN。 - TTL对准:
stick-table expire 30m必须匹配SESSION_TTL = 1800在server.py:18,否则棒表和会话状态可能会不同步地过期。
如果使用不同的负载平衡器(Nginx、Envoy、AWS ALB),概念是相同的:自定义标头上的粘性会话、SSE的延长超时、健康检查和故障转移路由。
6.使用弹性客户端
为什么? 服务器端基础设施处理路由和状态持久性,但MCP客户端必须参与恢复——它需要检测连接丢失、重新连接、调用 resume_session,然后重试失败的操作。
采用什么:
ResilientClient(resilient_client.py:10-70)--包装FastMCPClient随着__getattr__它拦截所有异步方法调用。- 出现故障时:指数回退(
resilient_client.py:65),重新连接+resume_session(resilient_client.py:36-50),然后重试原始呼叫。 - 用法:
async with ResilientClient("http://localhost:8080/mcp") as client:--直接替换Client.
如果没有客户端弹性,每次后端故障都需要调用者手动捕获错误、重新初始化、调用 resume_session,然后重试。请参阅上面的“弹性客户端”部分,了解完整的细分。
手动测试后端故障和恢复
# 1. Note which backend your session is on (from the curl test above)
# and save the session ID:
OLD_SESSION_ID="$SESSION_ID"
# 2. Stop that backend:
docker compose stop mcp-server-2
# 3. Wait for HAProxy health check (fall 3 × inter 5s = ~15s)
sleep 16
# 4. Re-initialize a new session (HAProxy redispatches to a healthy backend):
curl -s -D - http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "curl-test", "version": "1.0.0"}
}
}'
# Copy the new mcp-session-id from the response:
NEW_SESSION_ID="
"
# 5. Resume state from old session:
curl -s http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $NEW_SESSION_ID" \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"resume_session","arguments":{"old_session_id":"'"$OLD_SESSION_ID"'"}}}'
# 6. Verify counter is recovered:
curl -s http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $NEW_SESSION_ID" \
-d '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"get_counter","arguments":{}}}'
# 7. Restart the backend
docker compose start mcp-server-2项目结构
mcp-high-availability/
├── server.py # FastMCP server — tools, resources, health endpoint
├── session_store.py # SessionStore ABC + Session helper (JSON serde, TTL)
├── stores/
│ ├── redis_store.py # Redis-backed store (only file that imports redis.asyncio)
│ └── memory_store.py # Dict-backed store with lazy TTL expiry (local dev)
├── pyproject.toml # Python project config (fastmcp, httpx, redis)
├── .python-version # Python 3.12
├── Dockerfile # Server container image
├── docker-compose.yaml # Redis + 3 MCP servers + HAProxy
├── haproxy/
│ └── haproxy.cfg # HAProxy config with sticky sessions + redispatch
├── client.http # Step-by-step manual testing via VS Code REST Client
├── test_lb.py # HTTP-based infrastructure tests (HAProxy, load balancing)
├── test_mcp_client.py # Protocol tests using resilient client
├── test_resilience.py # Resilience test with container restarts
├── resilient_client.py # FastMCP Client wrapper with retry + session resumption
└── README.mdMCP服务器工具
服务器(server.py)公开这些工具,所有这些工具都返回 instance 字段显示哪个后端正在提供服务:
| 工具 | 说明 |
|---|---|
increment_counter | 增加会话范围计数器(证明粘性) |
get_counter | 获取当前计数器值 |
add_note | 在会话的注释列表中添加注释 |
list_notes | 列出会话的所有注释 |
get_server_info | 返回实例ID和活动会话计数 |
get_status | 返回实例ID、正常运行时间、活动会话计数和当前时间戳 |
analyze_data | 多步分析流程;通过SSE流式传输进度和日志通知 |
watch_counter | 轮询会话计数器以获取更改,并在每次更改时流式传输日志通知 |
resume_session | 将前一个会话的状态复制到当前会话中(用于崩溃/重新启动后的恢复) |
服务器还公开了一个 resource://session/{session_id}/summary 返回会话计数器、注释和上次分析结果的JSON快照的资源。
设计决策
为什么 leastconn 而非 roundrobin 还是一致散列?
问题: 会话具有不等的生存期和请求率。轮询在创建时平均分配会话,但随着时间的推移,一个后端可能会积累许多长期或高流量的会话,而另一个后端则处于空闲状态。
为什么不使用一致性哈希? 一致散列(hash(session-id) → backend)提供确定性路由,但它对实际后端负载视而不见。一旦会话哈希到节点,无论后端是否过载,它都会被固定在那里。它是为无状态系统中的缓存局部性而设计的,而不是为有状态系统中负载感知路由而设计的。
为什么 leastconn +棍子桌? 这结合了两个世界的优点:
- 负载感知初始分配: 新会话将转到活动连接最少的后端
- 会话关联度: 将现有会话固定到其指定的后端(O(1)哈希表查找)
- 外部化状态: Redis意味着任何后端都可以在故障转移后为任何会话提供服务
与Redis I/O(约200-1000µs)相比,棒表查找(约50-100ns)可以忽略不计,因此性能主要取决于状态存储,而不是路由策略。
主要发现
stick store-response res.hdr()与SSE响应配合使用。 HAProxy在正文流之前处理HTTP响应头,因此它捕获mcp-session-id即使正文是text/event-stream.- Redis将会话状态外部化。 所有值都是JSON序列化字符串,通过以下方式存储
SET/GET.计数器在Python中递增(读-修改-写)server.py),笔记存储为JSON数组。所有按键都有一个30分钟的滑动TTL,与HAProxy棒表到期时间相匹配。 option redispatch启用故障转移。 当粘性后端发生故障时,HAProxy会重新路由到健康的后端,而不是返回503。由于状态在Redis中,因此任何后端都可以为任何会话提供服务。- MCP会话ID是短暂的。 当后端崩溃时,MCP协议会话丢失,客户端必须重新初始化(获取新的
mcp-session-id).这resume_session该工具通过复制Redis密钥来桥接新旧会话。 ctx.session_id来自FastMCP (context.py)提供了mcp-session-idheader值——非常适合在Redis中键入状态。- 暂停对苏格兰和南方能源公司很重要。 这
timeout client/timeout server值必须很高(300秒)才能支持长期SSE流。这timeout tunnel(600)涵盖了升级的连接。
清理
docker compose down