Hồi đầu năm tôi Google “MCP SSE transport”, copy đoạn code mẫu, đưa lên một VPS, kết nối từ Claude Desktop. Chạy. Vài tuần sau khi đọc lại spec, tôi phát hiện SSE đã bị deprecate. Transport thật sự cần dùng là “Streamable HTTP”, một cái tên mà tôi tưởng là “tên khác của SSE” nhưng thực ra là chuẩn mới. Sai một bước nhỏ ở đầu, sau này phải viết lại toàn bộ tầng kết nối, và migrate hai server đã ship cho team.

Đây là bản đồ tôi muốn có lúc đó. Bài này nói thẳng: stdio cho hobby/local, Streamable HTTP cho prod, SSE bỏ đi. Mọi thứ còn lại là chi tiết.

3 transport, 1 protocol

Trước khi nói về từng loại transport, cần làm rõ một điều dễ gây nhầm lẫn: MCP không phải là một giao thức mạng. Nó là một protocol mã hoá ở tầng message. Tất cả MCP message đều là JSON-RPC 2.0, UTF-8 encoded, có cùng schema cho tools/list, tools/call, resources/read, prompts/get. Phần “chạy được” của MCP nằm ở chỗ JSON-RPC này phải đi qua một “ống” nào đó giữa client và server. Cái ống đó gọi là transport.

Hình dung như sau. Bạn viết một bức thư bằng tiếng Việt (JSON-RPC message). Bức thư đó có thể được gửi qua bưu điện, qua email, qua chuyển phát nhanh. Ngôn ngữ trong thư không đổi, chỉ cách vận chuyển khác nhau. Transport của MCP cũng vậy: cùng một message format, ba cách “mang” message giữa hai bên.

Spec hiện tại của MCP (version 2025-06-18) chính thức công nhận hai transport: stdioStreamable HTTP. Có transport thứ ba, HTTP+SSE, từng là chuẩn cũ (spec 2024-11-05) và giờ đã deprecated. Tôi vẫn dành riêng một section cho SSE vì rất nhiều tutorial trên mạng còn đang dùng nó, và bạn sẽ gặp code cũ phải migrate.

Quy ước trong bài: “client” là MCP host (Claude Desktop, Claude Code, custom agent), “server” là process expose tool. Mọi message đi qua transport đều là JSON-RPC.

stdio: process spawn, đơn giản, an toàn

stdio là transport dễ hiểu nhất. Client launch server như một subprocess. stdin của server là kênh client gửi message vào. stdout của server là kênh server trả message ra. stderr dành cho log (client có thể đọc hoặc bỏ qua).

+---------------------+              +---------------------+
|                     |  spawn       |                     |
|   MCP Client        |------------->|   MCP Server        |
|   (Claude Desktop)  |              |   (subprocess)      |
|                     |              |                     |
|                     |  stdin       |                     |
|                     |------------->|                     |
|                     |              |                     |
|                     |  stdout      |                     |
|                     |<-------------|                     |
|                     |              |                     |
|                     |  stderr (log)|                     |
|                     |<-------------|                     |
+---------------------+              +---------------------+

Message format trên dây: mỗi JSON-RPC message là một dòng, kết thúc bằng \n. Spec nói rõ message không được chứa ký tự newline embedded. Đây là điểm dễ sai nếu bạn tự pretty-print JSON: phải dùng JSON compact (không line break) khi ghi ra stdout.

Cái tôi thích nhất ở stdio là nó loại bỏ hết câu hỏi network khỏi đầu. Không cần port, không TLS, không auth, không CORS, không DNS rebinding, không cần worry về CSRF. Client biết chính xác server đến từ binary nào vì chính client spawn. Latency gần như free, chỉ là pipe giữa hai process trên cùng máy. Lifecycle gắn với client, client thoát thì server thoát theo, không có server mồ côi lơ lửng.

Đổi lại, stdio chỉ chạy local. Server phải cùng máy với client, không share giữa thiết bị. Mỗi client spawn một process riêng, mở hai cửa sổ Claude Desktop là có hai instance server, không share state, không share connection pool. Multi-user thì gần như không khả thi. Log của server đi qua stderr, không tự nhiên đi vào hệ thống log tập trung của prod, muốn forward phải tự code.

Quan điểm của tôi: stdio là default đúng cho desktop assistant (Claude Desktop, Cursor, editor có MCP), dev tool local (filesystem, git, local DB), và mọi tình huống single-user single-machine. Phần lớn MCP server công khai trên github.com/modelcontextprotocol/servers dùng stdio. Giữ default này trừ khi có lý do rõ ràng phải đổi.

SSE (Server-Sent Events): transport cũ, deprecated 2025

SSE transport có trong MCP spec phiên bản 2024-11-05. Tên đầy đủ trong spec là “HTTP with SSE”. Đây là transport remote đầu tiên của MCP. Khi bạn cần server chạy trên cloud, không thể spawn subprocess, SSE là câu trả lời ngày đó.

Cơ chế SSE dùng hai endpoint riêng:

  1. GET /sse (hoặc bất kỳ path nào): client mở một SSE connection. Server hold connection này mở, push message ra qua các event SSE liên tục.
  2. POST /messages (hoặc path khác): client gửi request đến endpoint này. Server xử lý, response đi ngược lại qua SSE stream của bước 1.
+-----------+                              +-----------+
|           |  GET /sse                    |           |
|  Client   |----------------------------->|  Server   |
|           |                              |           |
|           |  text/event-stream (open)    |           |
|           |<-----------------------------|           |
|           |                              |           |
|           |  event: endpoint             |           |
|           |  data: /messages?sid=abc     |           |
|           |<-----------------------------|           |
|           |                              |           |
|           |  POST /messages?sid=abc      |           |
|           |  {jsonrpc request}           |           |
|           |----------------------------->|           |
|           |                              |           |
|           |  event: message              |           |
|           |  data: {jsonrpc response}    |           |
|           |<-----------------------------|           |
+-----------+                              +-----------+

Server-Sent Events là một chuẩn web có sẵn trong browser từ lâu. Server giữ một HTTP response mở, gửi event text chunks định kỳ. Browser (hoặc client) parse các chunk đó như stream sự kiện.

Cái khó của SSE trong MCP không phải technical mà là operational. Hai endpoint, hai connection: client maintain SSE connection mở (long-poll) đồng thời gửi POST song song. Phức tạp hơn HTTP request-response thông thường, và nhiều middleware (reverse proxy, load balancer, WAF) xử lý SSE không thân thiện. CDN cấu hình mặc định buffer response, làm chết long-poll. Bạn phải bypass cache cho path SSE, không phải lúc nào cũng được phép trên hạ tầng team.

SSE là one-way server-to-client; client gửi vẫn qua POST riêng. Hai chiều split ra hai connection, state đồng bộ giữa hai bên dễ rối. Session ID nằm trong query param (sid) không chuẩn hoá, load balancer phải pin client vào đúng instance giữ stream, gãy nếu container restart. Network rớt thì client không biết server còn hold stream hay đã đóng, không có cơ chế resume từ event ID cuối cùng.

Timeline deprecate ngắn gọn. Spec 2024-11-05 công nhận SSE là transport remote chính thức. Spec 2025-03-26 đẻ ra Streamable HTTP, ghi rõ “This replaces the HTTP+SSE transport”. Spec 2025-06-18 hiện tại chỉ liệt kê stdio và Streamable HTTP, SSE chỉ còn trong section “Backwards Compatibility” như di sản.

Quan điểm của tôi: nếu bạn xây mới hôm nay, bỏ qua SSE hoàn toàn. Code mẫu trên blog cũ vẫn dùng SSEServerTransport, chạy được, nhưng đừng base code mới của bạn trên đó. Bạn sẽ tự đặt cho mình một deadline migration không cần thiết.

Streamable HTTP: chuẩn mới 2025

Spec 2025-03-26 của MCP giới thiệu Streamable HTTP làm transport remote chuẩn, thay thế HTTP+SSE. Spec 2025-06-18 (bản hiện tại) làm rõ và refine thêm. Tên gọi “Streamable HTTP” có vẻ dài, ý nghĩa thực sự là: HTTP request-response truyền thống, với khả năng nâng cấp response thành SSE stream khi cần thiết.

Cơ chế cốt lõi:

  • Một endpoint duy nhất. Server expose một URL ví dụ https://example.com/mcp chấp nhận cả POST và GET.
  • Client gửi request bằng POST. Body là JSON-RPC message. Header Accept phải có cả application/json lẫn text/event-stream.
  • Server chọn cách response. Hai option. Option một: trả về một JSON object thông thường với Content-Type: application/json. Đây là HTTP request-response cổ điển, đơn giản nhất. Option hai: trả về Content-Type: text/event-stream, mở SSE stream trong response. Server có thể push nhiều message qua stream này trước khi gửi response cuối. Dùng khi tool execution lâu hoặc cần stream progress.
  • Client tuỳ chọn GET để mở long-running stream. Khi client muốn nhận server-initiated message (như notification, server-to-client request), nó gửi thêm một GET request. Server có thể trả text/event-stream để mở stream hoặc 405 Method Not Allowed nếu không hỗ trợ.
+-----------+                              +-----------+
|           |  POST /mcp                   |           |
|  Client   |  Accept: application/json,   |  Server   |
|           |          text/event-stream   |           |
|           |  {jsonrpc request}           |           |
|           |----------------------------->|           |
|           |                              |           |
|           |  Content-Type:               |           |
|           |    application/json          |           |
|           |  {jsonrpc response}          |           |
|           |<-----------------------------|           |
|           |                              |           |
|           |  ===== HOAC =====            |           |
|           |                              |           |
|           |  Content-Type:               |           |
|           |    text/event-stream         |           |
|           |  event: message              |           |
|           |  data: {progress 1}          |           |
|           |  ...                         |           |
|           |  event: message              |           |
|           |  data: {jsonrpc response}    |           |
|           |<-----------------------------|           |
|           |                              |           |
|           |  GET /mcp (optional)         |           |
|           |  Accept: text/event-stream   |           |
|           |----------------------------->|           |
|           |  long-lived SSE stream       |           |
|           |  (server-to-client msgs)     |           |
|           |<.............................|           |
+-----------+                              +-----------+

Vài chi tiết quan trọng:

Session management. Server có thể assign session ID khi initialize bằng header Mcp-Session-Id trong response của InitializeResult. Client sau đó phải attach header này vào mọi request tiếp theo. Server có thể terminate session bất kỳ lúc nào (response 404), client phải mở session mới. Client có thể chủ động kết thúc bằng DELETE request kèm session ID.

Protocol version header. Spec 2025-06-18 thêm yêu cầu: sau khi negotiate version, client phải gửi header MCP-Protocol-Version: 2025-06-18 (hoặc version negotiated) trong mọi request HTTP sau đó. Server có thể serve các version khác nhau dựa trên header này. Nếu server nhận version invalid, response 400 Bad Request.

Resumability. Đây là điểm SSE cũ thiếu. Server có thể attach id field vào mỗi SSE event. Nếu connection bị broken, client có thể gửi GET request kèm header Last-Event-ID: <id>. Server có khả năng replay các message đã sent sau ID đó. Không hoàn hảo (server cần lưu buffer), nhưng đủ để xử lý network blip mà không mất state.

Bảo mật. Spec đưa ra ba yêu cầu cứng cho Streamable HTTP:

  1. Server phải validate Origin header để chặn DNS rebinding attack.
  2. Local server nên bind vào 127.0.0.1, không phải 0.0.0.0.
  3. Server nên implement authentication. Bài 4 của series sẽ đi sâu vào OAuth Resource Server pattern mà MCP khuyến nghị.

Streamable HTTP đẹp ở chỗ nó web-friendly. Một endpoint, request-response chuẩn, đi qua mọi reverse proxy/CDN/load balancer mà không cần config đặc biệt. Có thể stateless nếu workflow không cần session, server bỏ qua Mcp-Session-Id, mỗi POST tự đứng một mình và scale horizontal dễ. Có thể stateful khi cần qua session ID + protocol version header. Last-Event-ID giúp client recover sau network blip mà không mất state.

Đổi lại, complexity cao hơn stdio rõ rệt. Bạn phải nghĩ về Origin validation, CORS, auth, session management ngay từ ngày đầu. Resumability không free; server phải implement buffer + replay logic. SDK thường có sẵn, nhưng vẫn cần hiểu để debug. Server-side state khi dùng session là chỗ rất dễ tự đẩy mình vào pitfall stateless mà bài 2 đã warn.

Tôi chọn Streamable HTTP khi: server phải remote và phục vụ nhiều người (team internal tool, public service), server có auth thật (OAuth, bearer token, API key), prod deploy qua load balancer hoặc container orchestration, hoặc khi cùng server phải phục vụ cả desktop client (Claude Desktop) lẫn web client (custom dashboard, embedded widget). Bất kỳ tình huống nào trên đây không phải, stdio thường vẫn là lựa chọn đúng.

Migration từ SSE sang Streamable HTTP

Code base cũ đang dùng SSEServerTransport? Đây là bản đồ migration.

Bản đồ thay đổi:

AspectSSE (cũ)Streamable HTTP (mới)
EndpointHai endpoint: GET /sse + POST /messagesMột endpoint: POST /mcp + GET /mcp
Connection modelClient giữ SSE connection mở liên tụcPOST request-response, optional stream
SessionQuery param sid không chuẩn hoáHeader Mcp-Session-Id (chuẩn spec)
Protocol versionImplicitHeader MCP-Protocol-Version
ResumabilityKhông cóLast-Event-ID + replay
SDK class (TS)SSEServerTransportStreamableHTTPServerTransport
SDK class (Python)sse_serverstreamable_http_server

Breaking points cần kiểm tra:

  1. Client URL. Nếu client đang trỏ đến /sse endpoint, đổi sang endpoint mới (ví dụ /mcp). Spec không quy định path, chỉ yêu cầu một endpoint duy nhất xử lý cả POST lẫn GET.
  2. Session tracking. Nếu code cũ track session bằng query param sid, refactor để dùng header Mcp-Session-Id. Spec yêu cầu header này phải globally unique, cryptographically secure (UUID hoặc tương đương), chỉ chứa visible ASCII.
  3. Error handling. Spec mới quy định cụ thể: response notification/response success = 202 Accepted với body rỗng. Code SSE cũ có thể trả 200 OK với body JSON, không tương thích.
  4. Response content negotiation. Server phải đọc Accept header để quyết định trả JSON đơn lẻ hay stream. Code cũ luôn stream, code mới chọn theo từng request.

Backwards compatibility (theo spec): server có thể host song song cả hai endpoint trong giai đoạn chuyển đổi. Client thử POST InitializeRequest đến URL mới. Nếu HTTP 4xx (405, 404), fallback sang GET /sse kiểu cũ. Khi tất cả client đã migrate, gỡ endpoint SSE.

SDK đã hỗ trợ chưa? TypeScript SDK (@modelcontextprotocol/sdk) và Python SDK (mcp) đều đã ship StreamableHTTPServerTransport từ giữa năm 2025, đồng thời với release của spec 2025-03-26. Class cũ SSEServerTransport vẫn còn để hỗ trợ backwards compat, nhưng được mark deprecated trong doc. Kiểm tra changelog của SDK bạn dùng để confirm.

Code example

Hai example tối thiểu: cùng một MCP server, expose tool echo, viết bằng TypeScript. Một version chạy qua stdio, một version chạy qua Streamable HTTP.

stdio version:

// server-stdio.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "echo-server", version: "1.0.0" });

server.tool(
  "echo",
  { message: z.string() },
  async ({ message }) => {
    return {
      content: [{ type: "text", text: `echo: ${message}` }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Chạy: node server-stdio.js. Kết nối từ Claude Desktop bằng cách thêm vào claude_desktop_config.json:

{
  "mcpServers": {
    "echo": {
      "command": "node",
      "args": ["/path/to/server-stdio.js"]
    }
  }
}

Restart Claude Desktop, tool echo xuất hiện.

Streamable HTTP version:

// server-http.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { randomUUID } from "node:crypto";

const server = new McpServer({ name: "echo-server", version: "1.0.0" });

server.tool(
  "echo",
  { message: z.string() },
  async ({ message }) => {
    return {
      content: [{ type: "text", text: `echo: ${message}` }],
    };
  }
);

const app = express();
app.use(express.json());

// In-memory map session -> transport. Production code nen dung store ngoai
// (Redis, DB) de cho phep horizontal scale.
const transports: Record<string, StreamableHTTPServerTransport> = {};

app.all("/mcp", async (req, res) => {
  const sessionId = req.header("Mcp-Session-Id");
  let transport: StreamableHTTPServerTransport;

  if (sessionId && transports[sessionId]) {
    transport = transports[sessionId];
  } else {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => {
        transports[id] = transport;
      },
    });
    await server.connect(transport);
  }

  // Validate Origin (security requirement from spec).
  const origin = req.header("Origin");
  if (origin && !origin.startsWith("http://localhost")) {
    res.status(403).send("Origin not allowed");
    return;
  }

  await transport.handleRequest(req, res, req.body);
});

// Bind vao 127.0.0.1 thay vi 0.0.0.0 cho local dev.
app.listen(3000, "127.0.0.1", () => {
  console.error("MCP server listening on http://127.0.0.1:3000/mcp");
});

Chạy: node server-http.js. Test bằng curl:

# Initialize
curl -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": { "name": "curl", "version": "1.0" }
    }
  }' -i

Response sẽ có header Mcp-Session-Id: <uuid>. Lưu ID đó cho request tiếp theo:

# Call tool echo
curl -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Mcp-Session-Id: <uuid-tu-buoc-1>" \
  -H "MCP-Protocol-Version: 2025-06-18" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "echo",
      "arguments": { "message": "hello mcp" }
    }
  }'

Response: { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "echo: hello mcp" }] } }.

Để gắn server này vào Claude Desktop hoặc Claude Code, bạn cần một remote MCP client config (định dạng khác stdio, thường là URL trực tiếp). Tham khảo doc chính thức cho client cụ thể bạn đang dùng. Ở thời điểm bài này, Claude Desktop ưu tiên stdio cho local server, remote server thường được wrap qua một adapter chạy stdio cục bộ và forward sang HTTP.

So sánh hai code: phần logic tool (server.tool(...)) giống y hệt giữa stdio và HTTP version. Khác biệt nằm ở transport setup. Đây chính là điểm mạnh của MCP: tool logic transport-agnostic. Bạn viết một lần, ship hai dạng triển khai.

Khi nào dùng cái nào: bảng so sánh

Tiêu chístdioSSE (deprecated)Streamable HTTP
Trạng thái specChính thứcDeprecated 2025-03-26Chính thức (chuẩn mới)
Local hay remoteLocal onlyRemoteCả hai
EndpointN/A (subprocess)2 endpoint riêng1 endpoint duy nhất
Connectionstdin/stdout pipeSSE long-poll + POSTPOST + optional SSE
SessionTheo processQuery param sidHeader Mcp-Session-Id
AuthKhông cần (local)Tự buildOAuth Resource Server (spec recommends)
ResumabilityN/AKhôngLast-Event-ID
Stateless optionKhôngKhông
Scale qua LBKhôngKhó (sticky session)Dễ (stateless mode)
Dùng cho dev toolTốtKhôngOK
Dùng cho desktop assistantTốtOKOK
Dùng cho team internal toolKhôngKhông (deprecated)Tốt
Dùng cho public serviceKhôngKhông (deprecated)Tốt
ComplexityThấp nhấtTrung bìnhTrung bình cao

Một cách đơn giản để chọn:

  • Server chạy trên máy lập trình viên (filesystem, git, local DB), client cũng trên máy đó: stdio.
  • Server chạy trên cloud, accessible cho nhiều người dùng, có auth: Streamable HTTP.
  • Code cũ đang dùng SSE: migrate sang Streamable HTTP, không build mới với SSE.

Trường hợp gặp một MCP server trên GitHub viết bằng SSE: bạn vẫn có thể chạy được, vì client SDK còn backwards compat. Nhưng đừng base code mới của bạn trên đó.

Một câu nhớ

Cùng một JSON-RPC message, ba cách đưa nó từ client sang server. stdio cho local, Streamable HTTP cho remote, SSE là di sản nên gỡ. Một câu nhớ từ bài này, đủ rồi.

Còn câu hỏi không né được khi đưa MCP server ra public: ai được phép gọi tool. Public endpoint không auth nghĩa là bất kỳ ai có URL cũng gọi delete_repo hay send_email được. Spec chốt một pattern rõ ràng, không tự đẻ ra cái mới, không “API key trong header”. Bài tiếp sẽ đi sâu vào lý do.