StreamableHTTP chuyên sâu — Dual SSE và session lifecycle

Message & Transport LayerTrung cấp30 phút

Ở bài 10.3 bạn đã biết: StreamableHTTP dùng HTTP POST + SSE để giải quyết vấn đề bidirectional. Ý tưởng đó đẹp trong slides.

Bạn sẽ học được
  • Mô tả được chính xác sequence của một session StreamableHTTP từ handshake đến close: POST, GET SSE, session ID.
  • Giải thích được vì sao tool call tạo ra 2 SSE connection song song (primary + tool-specific).
  • Phân biệt được message nào đi qua kênh nào (progress vs log vs tool result).
  • Debug được session issue dựa trên dấu vết mcp-session-id header.
  • Biết cách đọc network traffic (DevTools, tcpdump) để xem chính xác kênh MCP hoạt động.

Toàn bộ session lifecycle — Một bản đồ đầy đủ

Cái nhìn đầu tiên: server chạy 2 loại kênh song song:

Tại sao phức tạp vậy? Let's đào từng phần.

  • Kênh (3) — Primary SSE — mở một lần sau handshake, sống đến khi session end.
  • Kênh (4) — Tool-specific SSE — mở riêng cho mỗi tool call, đóng khi kết quả gửi xong.
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│   STAGE 1: HANDSHAKE                                         │
│   ────────────────                                           │
│   [1]  POST /mcp                                             │
│        body: initialize request                              │
│        ─────────────────────▶ Server                         │
│        ◀───────────────────── Response                       │
│        headers: mcp-session-id: abc-123                      │
│        body:    InitializeResult                             │
│                                                              │
│   [2]  POST /mcp                                             │
│        header: mcp-session-id: abc-123                       │
│        body:   notifications/initialized                     │
│        ─────────────────────▶ Server (no response)           │
│                                                              │
│   STAGE 2: LONG-LIVED SSE CONNECTION                         │
│   ───────────────────────────────                            │
│   [3]  GET /mcp                                              │
│        header: mcp-session-id: abc-123                       │
│        header: Accept: text/event-stream                     │
│        ─────────────────────▶ Server                         │
│        ◀═════════════════════  (stays open indefinitely)     │
│             event stream from server                         │
│                                                              │
│   STAGE 3: TOOL CALL (dual SSE appears)                      │
│   ──────────────────────────────                             │
│   [4]  POST /mcp                                             │
│        header: mcp-session-id: abc-123                       │
│        body:   tools/call (id=7)                             │
│        ─────────────────────▶ Server                         │
│        ◀═════════════════════  (SSE response opens)          │
│             ┌──────────────────────────────┐                 │
│             │ notifications/progress (20%) │                 │
│             │ notifications/message (log)  │                 │
│             │ ... (more events)            │                 │
│             │ CallToolResult (id=7)        │                 │
│             └──────────────────────────────┘                 │
│             (SSE closes when tool result sent)               │
│                                                              │
│   MEANWHILE on connection [3]:                               │
│   Primary SSE có thể nhận bất cứ lúc nào:                    │
│   - notifications/progress từ tool khác                      │
│   - sampling/createMessage request từ server                 │
│   - roots/list request từ server                             │
│   ...                                                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Stage 1: Handshake — Nơi session ID xuất hiện

Handshake HTTP giống stdio ở chỗ cần 3 message initialize → InitializeResult → initialized, nhưng thêm 1 bước ngầm: server cấp mcp-session-id.

Bước [1]: Initialize POST

Client gửi POST đầu tiên không có session ID (vì chưa có). Server nhận request, tạo 1 session mới, trả response kèm header:

mcp-session-id là chuỗi bất kỳ server tạo ra (UUID, nanoid, bất cứ cách nào đảm bảo unique). Client phải ghi nhớ và gửi kèm trong mọi request sau.

Bước [2]: Initialized notification

Client gửi notification initialized với session ID:

HTTP/1.1 200 OK
Content-Type: application/json
mcp-session-id: abc-123-def

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": { "tools": {}, "sampling": {} },
    "serverInfo": { "name": "my-server", "version": "..." }
  }
}

Bước [2]: Initialized notification

Server không trả response (vì là notification). Đến đây session ở trạng thái "ready".

Tại sao cần session ID?

Stdio không cần session ID — vì mỗi client process spawn 1 server process riêng, 1-to-1 mapping rõ ràng. Trên HTTP, 1 server phục vụ nhiều client đồng thời — phải có cách phân biệt "request này đến từ client A hay client B". Session ID là cái label đó.

Bên cạnh đó, session ID còn để match POST request với SSE connection (stage 3) — critical cho dual SSE routing.

POST /mcp HTTP/1.1
mcp-session-id: abc-123-def
Content-Type: application/json

{"jsonrpc":"2.0","method":"notifications/initialized"}

Stage 2: Primary SSE — "Cửa sau" để server gọi client

Đây là bước khác biệt nhất so với HTTP API truyền thống. Client chủ động mở 1 GET request với Accept: text/event-stream:

Server không đóng connection này. Response vẫn streaming, mỗi event theo format SSE:

GET /mcp HTTP/1.1
mcp-session-id: abc-123-def
Accept: text/event-stream

Stage 2: Primary SSE — "Cửa sau" để server gọi client (tiếp)

Mỗi message là 1 event. SSE format có event: và data: prefix, cách nhau bởi \n\n.

Connection này là "always-on" — sống suốt đời session. Bất cứ khi nào server muốn gửi message nằm ngoài context của tool call (sampling request, progress toàn cục, resource updated notification...), nó push qua kênh này.

Vì sao không dùng connection này cho tất cả?

Câu hỏi hay. Nếu SSE primary làm được việc server → client, tại sao tool result không đi qua đây luôn?

Vì 2 lý do:

Đó là lý do xuất hiện tool-specific SSE ở stage 3.

  • Concurrency — nếu có nhiều tool chạy song song, mỗi cái trả result qua kênh chung thì client phải match bằng id cẩn thận. Khó debug.
  • Backpressure & ordering — tool result nên đi theo response của POST gốc (HTTP 2.0 hỗ trợ streaming response body). Giữ tool call và tool result gần nhau về mặt logic.
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}

event: message
data: {"jsonrpc":"2.0","id":42,"method":"sampling/createMessage","params":{...}}

...

Stage 3: Tool call với dual SSE

Khi client gửi POST tools/call, server không trả JSON thông thường — nó trả SSE stream:

Stream này đóng khi tool call hoàn thành. Trong thời gian tool chạy, stream này truyền:

HTTP/1.1 200 OK
Content-Type: text/event-stream

Stage 3: Tool call với dual SSE (tiếp)

Lưu ý: CallToolResult (id=7) gửi qua stream này, không qua primary SSE.

Vậy message nào đi qua kênh nào?

Đây là rule-of-thumb từ spec:

Logic chung: tool result + log trong tool → đi theo tool-specific SSE (giữ gần với request gốc). Progress và các server-initiated message khác → đi qua primary SSE (kênh sống cho toàn session).

Progress đi qua primary SSE dù về 1 tool cụ thể — progressToken trong payload sẽ cho client biết progress này thuộc tool call nào. Thiết kế này giúp primary SSE có thể handle progress của nhiều tool đang chạy song song mà không cần mở thêm connection.

MessageKênh
CallToolResultTool-specific SSE (kênh [4])
notifications/message (log) trong toolTool-specific SSE
notifications/progressPrimary SSE (kênh [3])
sampling/createMessage requestPrimary SSE
roots/list requestPrimary SSE
notifications/tools/list_changedPrimary SSE
notifications/resources/updatedPrimary SSE
event: message
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"Fetching data..."}}

event: message
data: {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Result..."}]}}

Ví dụ minh họa: 2 tool call đồng thời

Tưởng tượng client gọi 2 tool song song: research("A") và research("B"). Sequence:

4 connection song song, 1 kênh cho mỗi tool call + 1 primary + đôi khi thêm POST cho control. Client SDK phải biết route:

  • Message trên SSE A → thuộc tool call id=10.
  • Message trên SSE B → thuộc tool call id=11.
  • Message trên primary SSE → toàn cục, nhìn id trong body để assign.
Client                          Server
  │                               │
  │───── POST tool A (id=10) ────▶│
  │                               │  (spawn tool A execution)
  │◀═════ SSE A (open) ══════════ │
  │                               │
  │───── POST tool B (id=11) ────▶│
  │                               │  (spawn tool B execution)
  │◀═════ SSE B (open) ══════════ │
  │                               │
  │      (primary SSE ─▶)         │
  │◀──── progress: A 30% ─────────│
  │◀──── progress: B 20% ─────────│
  │                               │
  │      (SSE A)                  │
  │◀──── log: "A fetching..." ────│
  │      (SSE B)                  │
  │◀──── log: "B parsing..." ─────│
  │                               │
  │      (primary SSE)            │
  │◀──── progress: A 90% ─────────│
  │                               │
  │      (SSE A)                  │
  │◀──── CallToolResult (id=10) ──│  ← SSE A đóng
  │                               │
  │      (primary SSE)            │
  │◀──── progress: B 80% ─────────│
  │                               │
  │      (SSE B)                  │
  │◀──── CallToolResult (id=11) ──│  ← SSE B đóng
  │                               │
  │      (primary SSE vẫn open)   │
  │                               │

Debug checklist — Khi MCP over HTTP không hoạt động

Nếu server HTTP của bạn "không work", đây là sequence debug từ outside-in:

Checklist 1: Handshake

Nếu fail: check web server logs, chắc chắn body JSON parse được, route đúng.

Checklist 2: Primary SSE

Nếu SSE bị đóng ngay: check proxy/load balancer có buffer response không (Cloudflare default buffer — cần config để passthrough).

Checklist 3: Tool call routing

Checklist 4: Keep-alive & timeout

Nhiều proxy nhìn connection không data vài chục giây → tưởng dead và đóng. Server nên gửi comment mỗi 15-30s để keep-alive.

  • [ ] POST initialize có được response 200 + header mcp-session-id?
  • [ ] POST notifications/initialized có được phản hồi 202/200 và không trả body?
  • [ ] GET /mcp với Accept: text/event-stream có hold connection mở không?
  • [ ] Response có Content-Type: text/event-stream?
  • [ ] Header mcp-session-id đúng (trùng với lúc handshake)?
  • [ ] POST tool call có mcp-session-id đúng?
  • [ ] Response Content-Type của POST là text/event-stream hay application/json?
  • Nếu application/json → có thể server đã bật json_response=True, stream bị disable.
  • Nếu text/event-stream → stream đang hoạt động.
  • [ ] Primary SSE có bị proxy đóng sau 60s idle không? (Cloudflare default timeout).
  • [ ] Server có gửi comment heartbeat (SSE :\n event rỗng) để giữ kết nối không?

Ví dụ theo ngành

🛠️ Platform engineer debug production bug

Pain: User report "sampling không work cho tool X", nhưng server log show "sampling request sent". Debug 2 ngày không ra.

Giải pháp:

🔍 QA engineer design test suite

Pain: Cần test đầy đủ cả 2 chế độ (stream & non-stream) để đảm bảo không regression.

Giải pháp:

💰 DevOps define SLI/SLO cho MCP server

Pain: Team muốn track SLO cho MCP server nhưng metric HTTP thông thường (p99 latency) không reflect được SSE streaming.

Giải pháp:

  • Dùng DevTools inspect SSE primary connection → thấy nó bị đóng sau 60s do API gateway rule.
  • Vì sampling request gửi sau phút thứ 2 → primary SSE đã đóng → request bị mất.
  • Fix: thêm server-side heartbeat :heartbeat\n\n mỗi 30s.
  • Kết quả: Sampling hoạt động lại, total debug time 2 ngày → 30 phút sau khi biết cách inspect.
  • Test matrix: stateless_http × json_response = 4 combinations.
  • Mỗi combination test: progress emission, tool result, sampling request.
  • Dùng MCP Inspector làm client để verify visually.
  • Kết quả: Catch được 3 regression trong 1 quarter trước khi deploy production.
  • SLI mới:
  • "Time-to-first-byte của SSE response" (khi tool bắt đầu stream event).
  • "SSE connection uptime %" (primary SSE không bị đóng bất thường).
  • "Progress notification cadence" (khoảng cách giữa 2 progress notification, để detect tool bị treo).
  • Kết quả: Có dashboard chuyên biệt cho MCP, phát hiện degradation trước khi user complain.

Anti-patterns

❌ Không gửi mcp-session-id trong request sau initialize

Hiện tượng: Server trả 400 Bad Request hoặc tạo session mới mỗi request.

Cách đúng: Client phải lưu session ID từ response đầu tiên, append vào mọi request tiếp theo. SDK tự làm, nhưng nếu build raw phải cẩn thận.

❌ Dùng 1 session ID cho nhiều client

Hiện tượng: User 1 thấy progress của user 2.

Cách đúng: Mỗi client khởi tạo session riêng. Session ID không phải auth token — không share.

❌ Coi SSE là WebSocket

Hiện tượng: Client code tự đóng SSE rồi mở lại liên tục, giống WebSocket reconnect.

Cách đúng: SSE có built-in reconnect mechanism (header Last-Event-ID). Để SDK hoặc browser handle, đừng tự force reconnect trừ khi network hỏng.

❌ Log mcp-session-id ra monitoring không redact

Hiện tượng: Session ID float trong logs → potential để attacker hijack session nếu thô.

Cách đúng: Redact session ID trong production logs giống như redact auth token. Hoặc log chỉ hash của session ID.

❌ Hardcode path /mcp

Hiện tượng: Server path là /api/v2/mcp, client chỉ trỏ /mcp → 404.

Cách đúng: Spec cho phép path tùy ý. Client config phải trỏ full URL. Tên path /mcp chỉ là quy ước trong tutorial.

Mẹo nâng cao

Mẹo 1: Enable HTTP/2 để SSE scale tốt hơn

HTTP/1.1 giới hạn 6 concurrent connection per origin. Với dual SSE + tool calls, bạn có thể hit limit này nhanh. HTTP/2 multiplex nhiều stream trên 1 connection → không lo.

Cấu hình trong uvicorn:

Mẹo 2: Gửi SSE comment để keep-alive

uvicorn main:app --http h2

Mẹo 2: Gửi SSE comment để keep-alive

Client bỏ qua comment nhưng proxy thấy traffic → không đóng connection.

Mẹo 3: Monitor SSE buffer size

Một số proxy/load balancer có buffer size limit cho SSE response. Nếu server gửi event lớn (> 1MB) → bị cắt. Workaround: chunk tool result thành nhiều message notification trước khi gửi final result.

Mẹo 4: Tận dụng Last-Event-ID để resume

SSE chuẩn hỗ trợ header Last-Event-ID — khi disconnect rồi reconnect, client gửi kèm ID cuối cùng nhận được. Server có thể replay các event missed. SDK chính thức chưa implement đầy đủ, nhưng nếu bạn write custom client, đây là pattern robust.

Mẹo 5: Dùng nginx config chuẩn cho SSE

async def event_stream():
    while True:
        if not client_disconnected:
            yield f": heartbeat\n\n"  # SSE comment
            await asyncio.sleep(20)

Mẹo 5: Dùng nginx config chuẩn cho SSE

proxy_buffering off là chìa khóa — nếu buffer on, SSE event kẹt trong nginx, không stream ra client.

location /mcp {
    proxy_pass http://mcp_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;               # quan trọng
    proxy_cache off;                   # quan trọng
    proxy_read_timeout 24h;            # cao để không timeout SSE
}

Áp dụng ngay

Bài tập 1: Inspect SSE traffic bằng DevTools (20 phút)

Bước 1: Chạy server uv run calc.py với transport="streamable-http" từ bài 10.3.

Bước 2: Mở Chrome, trỏ tới http://localhost:8000/mcp (tab trống, sẽ 405 method not allowed — OK).

Bước 3: Mở DevTools → Network tab. Filter Fetch/XHR.

Bước 4: Dùng MCP Inspector kết nối tới server:

Bước 5: Trong Inspector, call tool add. Trong DevTools, quan sát:

Bài tập 2 (optional): Viết heartbeat endpoint test

Sửa calc.py thêm tool slow_compute sleep 90 giây rồi return. Deploy sau một proxy có 60s idle timeout (nginx default). Quan sát: tool có trả về được không? Thử thêm heartbeat SSE comment → có khác không?

  • Bao nhiêu request có path /mcp? ___________
  • Request nào có Content-Type: text/event-stream? ___________
  • Header mcp-session-id value (redact phần cuối): ___________
npx @modelcontextprotocol/inspector http http://localhost:8000/mcp

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

🎯 Session ID sinh ở bước initialize — xuất hiện trong header response đầu tiên, client phải gửi kèm mọi request sau.

🎯 Primary SSE = "cửa sau" — mở 1 lần, sống đến hết session, server dùng để gửi request & progress.

🎯 Tool call có SSE riêng — mở khi POST tool, đóng khi tool result gửi xong.

🎯 Routing rule dễ nhớ — tool result & log trong tool → tool SSE. Mọi thứ khác (progress, sampling, roots, broadcast) → primary SSE.

🎯 Proxy là kẻ thù của SSE — buffer on, timeout ngắn, strip header → phá SSE. Luôn config passthrough.

🎯 Keep-alive bằng SSE comment — gửi : heartbeat\n\n mỗi 20-30s để tránh proxy đóng kết nối.

Tài liệu tham khảo
  • MCP Streamable HTTP Transport spec
  • W3C SSE Specification
  • nginx Proxy Configuration for SSE
  • Transcript: "Building with MCP and the Claude API" (2025-10-09) — thảo luận về remote MCP & registry
Nội dung này có hữu ích không?