Lộ trình học›MCP›MCP: Advanced Topics Mở bản đồ

10.5 · State, stateless_http và json_response — Hai flag quyết định production

Bài viếtNâng cao30 phút đọc

Nếu bạn từng tìm Google với câu này, bạn đã chạm đến central drama của MCP trong production. Câu trả lời ngắn: bạn đã vượt qua ngưỡng mà 1 instance không giải quyết được nữa, và 2 flag stateless_http + json_response chính là đòn bẩy bạn có.

Sau bài này, bạn sẽ
  1. Giải thích được vì sao horizontal scaling lại khó khăn với stateful MCP server, và lý thuyết đằng sau.
  2. Liệt kê chính xác 5 tính năng bị disable khi bật stateless_http=True.
  3. Phân biệt được hiệu ứng của stateless_http (cấu trúc session) và json_response (streaming).
  4. Áp dụng được decision framework để chọn đúng flag cho deployment scenario cụ thể.
  5. Biết được các giải pháp thay thế nếu không muốn bật flag nhưng vẫn cần scale (sticky session, tier architecture).

Mở đầu: Câu hỏi kinh điển nhất của mọi MCP developer

Tại sao code của tôi chạy hoàn hảo trên stdio + single instance HTTP, nhưng sau khi scale lên 5 replica qua load balancer, sampling không work, progress bar biến mất, user complain tool bị treo?

Bài này dạy bạn:

  1. Vì sao sự cố trên xảy ra (về mặt kiến trúc).
  2. 2 flag làm gì, bật thì mất gì, giữ thì đau ở đâu.
  3. Framework để quyết định.
  4. Các workaround nếu bạn không chịu trade-off.

Tại sao scaling MCP lại khó — Câu chuyện kiến trúc

Điểm xuất phát: 1 instance, 10 user

Ngày đầu, MCP server của bạn chạy trên 1 container. 10 user. Mỗi user mở 1 session với 1 primary SSE connection. Server giữ 10 SSE connection trong RAM, map mỗi connection tới mcp-session-id. Mọi thứ ổn.

┌─────────────────────────────┐
│      MCP Server (1 pod)      │
│                              │
│  sessions = {                │
│    "sid-A": conn_A,          │
│    "sid-B": conn_B,          │
│    ...                       │
│  }                           │
└─────────────────────────────┘
          ▲  ▲  ▲
          │  │  │
       User A B C...

Vấn đề: 10,000 user muốn dùng cùng lúc

Server tải nặng, CPU 100%. Giải pháp kinh điển: horizontal scaling — deploy nhiều replica sau load balancer.

                       ┌─────────────┐
                       │ Load Balancer│
                       └──────┬──────┘
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
     ┌────────────┐   ┌────────────┐   ┌────────────┐
     │ MCP pod 1  │   │ MCP pod 2  │   │ MCP pod 3  │
     └────────────┘   └────────────┘   └────────────┘

Và đây là lúc mọi thứ bắt đầu gãy. Lý do: load balancer mặc định random routing — request nào đến instance nào là ngẫu nhiên.

Nhớ lại bài 10.4: client cần 2 kênh đến server:

  • GET primary SSE — kết nối long-lived.
  • POST tool call — mỗi lần gọi tool.

Nếu:

  • GET SSE của user A đi vào pod 1 (pod 1 giữ connection).
  • POST tool call của user A đi vào pod 2.

Pod 2 xử lý tool call, muốn gửi progress qua primary SSE → nhưng SSE đó ở pod 1. Pod 2 không có cách gửi, progress lost. Tool xong cần sampling → sampling request phải đi qua primary SSE → ở pod 1 → pod 2 không gọi được → sampling fail.

┌─────────────────────────────────────────────────────────┐
│                                                         │
│   USER A                                                │
│    │                                                    │
│    │ GET SSE  ───▶ Pod 1 (giữ connection)               │
│    │                                                    │
│    │ POST tool ──▶ Pod 2 (muốn gửi progress)            │
│    │                   │                                │
│    │                   ▼                                │
│    │              ❌ Không có connection trong Pod 2     │
│    │                                                    │
│    │              ❌ Progress không đến user             │
│    │              ❌ Sampling không đến user             │
│                                                         │
└─────────────────────────────────────────────────────────┘

Đây là coordination problem giữa các pod.

2 giải pháp mà protocol designer cân nhắc

Option A: Share state giữa các pod.

Dùng Redis, Postgres LISTEN/NOTIFY, hoặc message queue. Pod 2 gửi progress vào Redis pub/sub, pod 1 subscribe → đọc ra và forward cho client.

Workable nhưng phức tạp:

  • Infrastructure phức tạp hơn (thêm Redis, thêm operational load).
  • Latency thêm một hop.
  • Failure mode mới (Redis down thì sao?).

Option B: Loại bỏ state. Accept mất vài tính năng.

stateless_http=True. Không session, không primary SSE, không routing problem. Trade feature lấy simplicity.

SDK chọn option B làm sẵn có (1 flag), và để option A cho developer tự implement nếu cần.

Đây là cái khó: nếu bạn muốn cả 2 (scale + full features), bạn phải tự build infra coordinate. Spec không giúp.

stateless_http=True — Bật lên thì mất gì

Flag này hiểu đơn giản: "tôi sẽ treat mỗi request HTTP như standalone, không track session ID, không SSE persistent".

Khi bật:

┌─────────────────────────────────────────────────────────┐
│   TRƯỚC (stateless_http=False, default)                 │
│   ──────────────────────────────────                    │
│   ✅ mcp-session-id cấp trong initialize                 │
│   ✅ GET /mcp SSE long-lived (primary channel)           │
│   ✅ Progress notifications                              │
│   ✅ Log notifications                                   │
│   ✅ Sampling (server→client request)                    │
│   ✅ Roots (server→client request)                       │
│   ✅ Resource updated notifications                      │
│   ✅ Subscriptions / list_changed events                 │
│                                                         │
│   SAU (stateless_http=True)                             │
│   ─────────────────────                                 │
│   ❌ Không session ID                                    │
│   ❌ Không primary SSE (GET /mcp trả 405)                │
│   ❌ Không progress                                      │
│   ❌ Không log streaming                                 │
│   ❌ Không sampling                                      │
│   ❌ Không roots request từ server                       │
│   ❌ Không subscriptions                                 │
│                                                         │
│   Bù lại, được gì?                                      │
│   ✅ Client KHÔNG cần initialize (tool call trực tiếp)   │
│   ✅ Horizontal scale trivial — bất kỳ pod nào cũng OK   │
│   ✅ Infrastructure đơn giản — chỉ POST, không SSE       │
│                                                         │
└─────────────────────────────────────────────────────────┘

Note đặc biệt: initialize cũng bị skip. Client gửi tools/call thẳng, server xử lý. Pattern giống REST API hơn là MCP.

Quy tắc vàng của stateless_http=True

Khi bật flag này, coi MCP server của bạn như tập hợp tool REST API với JSON Schema input/output. Không dùng được các tính năng phụ thuộc bidirectional. Và đó là OK — nhiều use case chỉ cần vậy.

json_response=True — Tắt streaming

Flag thứ 2 đơn giản hơn nhiều:

  • Mặc định (False): tool call response đi qua SSE stream — có thể emit log, progress, rồi final result.
  • True: tool call response là JSON thuần, một cục — không stream. Chỉ có final result.
┌────────────────────────────────────────────────────────┐
│                                                        │
│  json_response=False (default)                         │
│  ──────────────────────                                │
│  POST /mcp                                             │
│    Content-Type: text/event-stream (response)          │
│  event: message                                        │
│  data: {progress 20%}                                  │
│  event: message                                        │
│  data: {progress 50%}                                  │
│  event: message                                        │
│  data: {log: "processing..."}                          │
│  event: message                                        │
│  data: {CallToolResult}                                │
│  (stream close)                                        │
│                                                        │
│  json_response=True                                    │
│  ─────────────────                                     │
│  POST /mcp                                             │
│    Content-Type: application/json (response)           │
│  {                                                     │
│    "jsonrpc": "2.0", "id": 7,                          │
│    "result": { ... CallToolResult ... }                │
│  }                                                     │
│  (no progress, no log during execution)                │
│                                                        │
└────────────────────────────────────────────────────────┘

Khi nào bật json_response=True?

  • Proxy/API gateway không support SSE passthrough đúng cách.
  • Client bạn control là CLI hoặc backend service — không cần progress UI.
  • Tool của bạn vốn chạy nhanh (< 1-2s), progress không có giá trị.

Điểm mấu chốt: flag này chỉ ảnh hưởng POST response

Primary SSE (nếu stateless_http=False) vẫn hoạt động. Server vẫn có thể gửi sampling request qua primary SSE. Chỉ là tool call-specific stream mất.

Ma trận so sánh 4 combinations

stateless_httpjson_responseServer→client?Progress/Log?Scale?Khi nào dùng
False (default)False (default)FullFullCần sticky sessionSingle instance / small scale prod
FalseTrueQua primary SSEChỉ qua primary SSECần sticky sessionProxy buffer không được, giữ sampling
TrueFalseTrivialHiếm — hầu như không logic (false ambiguity)
TrueTrueTrivialPublic API scale lớn, không cần bidirectional

Dòng 3 (stateless_http=True + json_response=False) hợp lệ nhưng ít phổ biến: bạn giữ streaming cho POST response (log / intermediate data trong tool call) nhưng bỏ primary SSE (sampling, global progress). Dùng khi tool cần log nhưng server không cần bidirectional request.

Dòng 4 là chế độ "MCP như REST API" — đơn giản nhất, scale nhất, nhưng năng lực thấp nhất.

Decision framework — Chọn flag ra sao

Đây là flow hỏi đáp giúp bạn quyết định:

┌─────────────────────────────────────────────────────────┐
│                                                         │
│   1. Bạn có chạy trên >1 instance không?                │
│      ├── KHÔNG → stateless_http=False, OK single.       │
│      └── CÓ ↓                                           │
│                                                         │
│   2. Có thể dùng session affinity (sticky session)?     │
│      ├── CÓ → stateless_http=False + sticky.            │
│      │        Giữ full features, scale được.            │
│      └── KHÔNG ↓                                        │
│                                                         │
│   3. Server có cần sampling / roots / progress không?   │
│      ├── CÓ → Xây Redis pub/sub layer để share state.   │
│      │        Phức tạp, cần infra mới.                  │
│      │        HOẶC chia tier: stateful pool riêng.      │
│      └── KHÔNG → stateless_http=True. Sống với thiếu.   │
│                                                         │
│   4. Proxy/gateway của bạn hỗ trợ SSE không?            │
│      ├── CÓ → json_response=False, stream tool result.  │
│      └── KHÔNG → json_response=True, chấp nhận thiếu    │
│                  progress/log trong tool.               │
│                                                         │
└─────────────────────────────────────────────────────────┘

Quy tắc thực tế:

  • Dev local → tất cả False. Test full feature.
  • Staging / small prod → tất cả False + single instance. Monitor throughput.
  • Large prod → hỏi 4 câu trên.

Sticky session — Workaround số 1

Nếu bạn muốn giữ full features mà vẫn scale, sticky session (session affinity) là giải pháp phổ biến nhất.

Cách hoạt động: Load balancer dùng cookie hoặc hash của session ID để "đóng đinh" client A vào pod 1 cho toàn bộ session.

Cấu hình nginx:

upstream mcp_backend {
    ip_hash;  # hash theo IP client
    server pod1:8000;
    server pod2:8000;
    server pod3:8000;
}

Cấu hình Kubernetes Service:

apiVersion: v1
kind: Service
spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600

Trade-off:

  • Giữ full features, scale multi-pod.
  • Pod restart (deploy, rollout) → mất connection của users trên pod đó. Phải implement graceful reconnect.
  • Load distribution không đồng đều (client A connect mãi pod 1 → pod 1 load cao).

Tier architecture — Workaround số 2

Thay vì 1 deployment giải quyết hết, chia 2 tier:

                      ┌──────────────┐
                      │ Load Balancer│
                      └──────┬───────┘
              ┌──────────────┴────────────────┐
              ▼                               ▼
     ┌─────────────────┐             ┌─────────────────┐
     │ STATELESS TIER  │             │ STATEFUL TIER   │
     │ (10-50 pods)    │             │ (2-5 pods, sticky)│
     │                 │             │                 │
     │ Quick tools:    │             │ Heavy tools:    │
     │ - lookup        │             │ - research      │
     │ - transform     │             │ - summarize     │
     │ - search        │             │ - chain calls   │
     │                 │             │                 │
     │ stateless_http  │             │ stateless_http  │
     │     = True      │             │     = False     │
     └─────────────────┘             └─────────────────┘

Client gọi đúng tier dựa trên tool:

  • Tool đơn giản → stateless tier (scale cao, chi phí thấp).
  • Tool phức tạp cần sampling/progress → stateful tier.

Phức tạp hơn nhưng production-realistic cho SaaS lớn.

Dev vs Production mismatch — Bug nguồn số 1

Anti-pattern: Dev stdio, deploy HTTP, không test HTTP ở dev

Developer viết code, mcp.run() default stdio. Test local với Claude Desktop. Mọi thứ work.

Deploy production với mcp.run(transport="streamable-http", stateless_http=True). Tool sampling fail. Bug mất 2 tuần để debug vì code path khác.

Khuyến nghị: Luôn test transport prod ở dev

import os

transport = os.environ.get("MCP_TRANSPORT", "stdio")
stateless = os.environ.get("MCP_STATELESS", "false").lower() == "true"
json_resp = os.environ.get("MCP_JSON_RESPONSE", "false").lower() == "true"

if __name__ == "__main__":
    if transport == "stdio":
        mcp.run()
    else:
        mcp.run(
            transport=transport,
            stateless_http=stateless,
            json_response=json_resp,
        )

CI test với cả 3 config:

MCP_TRANSPORT=stdio pytest tests/
MCP_TRANSPORT=streamable-http pytest tests/
MCP_TRANSPORT=streamable-http MCP_STATELESS=true pytest tests/

Bug caught ngay trong CI, không để lộn sang prod.

Ví dụ theo ngành

SaaS B2B với MCP cho enterprise customer

Pain: Có cả tool đơn giản (search tickets) và tool phức tạp (summarize week, cần sampling). Enterprise customer expect full features.

Giải pháp:

  • Single tier với sticky session (Kubernetes ClientIP affinity).
  • 5 pods trong pool, grace shutdown 60s.
  • Max 100 concurrent session per pod → scale gần linear.
  • Kết quả: 500 customer concurrent, feature đầy đủ, no bug related to state routing.

DevTool open-source maintainer

Pain: MCP server public, free tier. Không muốn tự trả tiền sampling.

Giải pháp:

  • stateless_http=True. Mất sampling hoàn toàn — thay bằng docs hướng dẫn user enable sampling ở client.
  • Chấp nhận không có progress. Thiết kế tool chạy < 3s.
  • Deploy Cloudflare Workers (serverless) — scale tới infinite.
  • Kết quả: 50k user active, $0 server cost. Trade-off: UX không xịn như server stateful.

Internal platform team expose MCP cho data team

Pain: Các data analyst dùng MCP để Claude query warehouse. Query có thể 2-5 phút. Cần progress.

Giải pháp:

  • Single instance (chỉ 50 user total).
  • stateless_http=False, json_response=False. Full features.
  • Dùng tcp keep-alive + SSE heartbeat để tránh timeout.
  • Kết quả: Feature đầy đủ, setup đơn giản nhất, không cần scale gì thêm.

Payment provider expose MCP để checkout

Pain: Bắt buộc stateless vì load balancer của họ không hỗ trợ sticky session. Nhưng tool payment cần sampling để tóm tắt cart.

Giải pháp:

  • Thay vì sampling, server gọi Claude trực tiếp với subscription key riêng.
  • stateless_http=True, json_response=True. Trade-off: pay API cost, gain simplicity.
  • Có rate limit để tránh abuse.
  • Kết quả: Deploy done trong 2 tuần. Cost predictable ($0.02/call).

Anti-patterns

Bật stateless_http=True chỉ vì "an toàn hơn"

Hiện tượng
Dev đọc docs, thấy flag có thể bật, "bật cho chắc".
Cách đúng
Flag này giảm khả năng, không "an toàn hơn". Chỉ bật khi bạn thực sự cần scale horizontal mà không có sticky session.

Không log flag config vào startup log

Hiện tượng
Production chạy không đúng config mà không ai biết.
Cách đúng
Log rõ ràng khi server start — nhớ ghi ra stderr (stdout là kênh JSON message của stdio transport, in prose sẽ phá protocol):
import sys
print(f"[MCP] transport={transport} stateless={stateless} json_response={json_resp}",
      file=sys.stderr)

Mỗi lần user báo bug, check log xem config lúc đó.

Coi json_response=True là "tối ưu performance"

Hiện tượng
"JSON nhẹ hơn SSE đúng không?" → bật json_response=True để "tăng tốc".
Cách đúng
Overhead SSE negligible. Sự khác biệt thực sự là user experience (progress visible vs. black box). Chọn dựa trên UX không phải micro-optim.

Flip flag mà không test lại toàn bộ

Hiện tượng
Production đổi từ stateful sang stateless, không regress test.
Cách đúng
Mỗi thay đổi flag = test matrix đầy đủ. Các tool phụ thuộc bidirectional → test kỹ.

Quên graceful shutdown khi dùng sticky session

Hiện tượng: Deploy rollout, pod bị kill, 100 client mất kết nối đột ngột.

Cách đúng:

  • SIGTERM → stop nhận connection mới, drain existing session.
  • Client SDK retry logic → auto reconnect tới pod khác (mất state mềm OK).

Mẹo nâng cao

Mẹo 1: Feature flag detection phía client

Sau handshake, client có thể check serverInfo.capabilities. Nếu thiếu sampling, subscriptions... — biết ngay server đang stateless mode, adapt UI tương ứng.

Mẹo 2: Dùng tool metadata để hint

Server có thể đánh dấu tool nào phụ thuộc bidirectional trong description:

@mcp.tool(
    name="summarize",
    description="Summarize long text. Requires sampling support."
)

Client thấy keyword "sampling" → có thể fallback hoặc error graceful.

Mẹo 3: Redis pub/sub pattern để giữ bidirectional ở multi-pod

Nếu bạn quyết định build option A (share state), pattern thường dùng:

await redis.subscribe(f"session:{sid}")
async for message in redis.listen():
    await sse_writer.send(message)

await redis.publish(f"session:{sid}", progress_message_json)

Latency thêm ~1-5ms nhưng cho phép full features + full scale.

Mẹo 4: Observe 2 metric riêng biệt

Distinguish:

  • mcp_sessions_active (số session đang có primary SSE).
  • mcp_requests_total{route="tools/call"} (số request POST tool).

Tỷ lệ active/total cho biết transport đang stateful hay stateless effectively.

Mẹo 5: Đọc SDK source để biết chi tiết

Khi doubt, đọc python-sdk/src/mcp/server/streamable_http.py. SDK là nguồn truth — behavior của flag được implement rõ ràng trong code.

Áp dụng ngay

Bài tập 1: Test 4 combinations (30 phút)

Bước 1: Tạo server với 2 tool:

  • add(a, b) — nhanh.
  • research(topic) — await asyncio.sleep(3) + ctx.report_progress(50, 100) + ctx.info("researching...").

Bước 2: Chạy server với env var điều khiển 2 flag:

MCP_STATELESS=false MCP_JSON_RESPONSE=false uv run server.py

MCP_STATELESS=true MCP_JSON_RESPONSE=false uv run server.py

MCP_STATELESS=false MCP_JSON_RESPONSE=true uv run server.py

MCP_STATELESS=true MCP_JSON_RESPONSE=true uv run server.py

Bước 3: Với mỗi case, dùng MCP Inspector gọi research, ghi lại:

  • Progress có hiện không? ___________
  • Log có xuất hiện không? ___________
  • Tool result có trả về đúng không? ___________

Bước 4: Điền bảng:

CaseProgressLogResult OKDùng cho
False / False___________
True / False___________
False / True___________
True / True___________

Bài tập 2 (optional): Viết decision doc cho team

Viết 1 trang markdown ghi rõ:

  • Setup production hiện tại (1 instance? multi? có sticky?).
  • Các tool có cần bidirectional không?
  • Config flag được chọn vì lý do gì.

Document này sẽ cứu bạn sau 6 tháng khi onboard developer mới hoặc đổi infra.

Tóm tắt bài học

Những điều cốt lõi
  • Scaling MCP = scaling stateful system — không đơn giản như scale REST API, vì có SSE primary connection.
  • stateless_http=True = disable bidirectional — mất sampling, roots, progress, log, subscriptions. Đổi lại: scale trivial.
  • json_response=True = disable streaming response POST — mất progress/log trong tool call. Không ảnh hưởng primary SSE.
  • 4 combinations, 4 tradeoff — chọn dựa trên: số instance, có sticky session không, có cần bidirectional không.
  • Sticky session là workaround quan trọng nhất — giữ full features + scale multi-pod. Trade-off: rollout phức tạp hơn.
  • Test matrix trong CI — mỗi combination cần regression test. Dev stdio, deploy HTTP mà không test HTTP = tự chuốc bug.
  • Đây là nguồn bug hàng đầu trong MCP production — rất nhiều post-deploy bug mà developer report trên forum có gốc ở flag config sai hoặc không match transport test local.

Tài liệu tham khảo

Kiểm tra nhanh1 câu hỏi · chọn đáp án để xem giải thích
    Bạn đã hoàn thành bài này chưa?Tiến độ lưu trên trình duyệt của bạn.

    Khóa học cùng lộ trình