- Sampling cho phép MCP Server yêu cầu Client gọi mô hình ngôn ngữ (Claude) thay mặt mình, thay vì Server tự tích hợp và tự trả chi phí token — Client giữ credential và trả phí, lý tưởng cho public MCP server.
- Logging và Progress notifications gửi log và tiến độ real-time từ Server về Client trong lúc tool chạy — thuần cải thiện trải nghiệm, hoàn toàn tùy chọn.
- Roots cấp cho MCP Server quyền truy cập một số file/thư mục local và ngữ cảnh để Claude tự tìm file (list_roots + read_dir); nhưng SDK không tự thực thi ranh giới — dev phải tự kiểm bằng hàm như is_path_allowed().
- MCP giao tiếp bằng JSON-RPC 2.0 với hai nhóm message: Request–Result (đi theo cặp, ghép bằng id, có phản hồi) và Notification (một chiều, không cần phản hồi); là giao thức hai chiều nên cả client và server đều gửi được — điều quan trọng khi chọn transport như streamable HTTP.
TL;DR — Sampling cho phép MCP Server mượn mô hình ngôn ngữ (Claude) qua Client thay vì tự gọi trực tiếp. Server soạn prompt rồi nhờ Client gọi Claude; Client giữ credential và trả tiền token. Nhờ vậy Server không cần API key, không tích hợp mô hình, không ôm chi phí — lý tưởng cho public MCP server (mỗi user tự trả phí của mình).
Sampling là gì
Sampling là cơ chế cho phép một MCP Server truy cập một mô hình ngôn ngữ như Claude thông qua MCP Client đang kết nối với nó. Thay vì Server tự gọi Claude, nó nhờ Client thực hiện cuộc gọi đó thay mặt mình. Cách làm này dịch chuyển trách nhiệm và chi phí sinh văn bản từ Server sang Client.
Điểm mấu chốt cần nhớ: hướng gọi bị đảo ngược. Thông thường ta nghĩ "Server gọi API mô hình". Với sampling thì Server chỉ soạn yêu cầu, còn Client mới là bên thật sự gọi mô hình — vì Client thường đã có sẵn kết nối và credential.
Bài toán mà Sampling giải quyết
Giả sử có một MCP Server với một research tool cào thông tin từ Wikipedia. Sau khi gom xong dữ liệu, ta cần tóm tắt nó thành một báo cáo mạch lạc. Có hai lựa chọn:
- Lựa chọn 1 — cho Server truy cập Claude trực tiếp. Server phải có API key riêng, tự xử lý authentication, tự quản lý chi phí và tự viết toàn bộ code tích hợp Claude. Chạy được, nhưng thêm rất nhiều phức tạp — và nếu là server công khai thì Server gánh chi phí AI cho mọi user.
- Lựa chọn 2 — dùng Sampling. Server soạn một prompt và hỏi Client: "Gọi Claude giúp tôi được không?". Client — vốn đã có kết nối tới Claude — thực hiện cuộc gọi và trả kết quả về.
Cách Sampling hoạt động (luồng 6 bước)
Luồng khá thẳng: Server làm xong việc của mình, soạn prompt, gửi sampling request sang Client; Client gọi Claude rồi trả text về để Server dùng trong kết quả cuối.
| # | Bên thực hiện | Hành động |
|---|---|---|
| 1 | Server | Hoàn tất công việc của mình (vd: fetch các bài Wikipedia) |
| 2 | Server | Tạo một prompt yêu cầu sinh văn bản |
| 3 | Server | Gửi sampling request sang Client |
| 4 | Client | Gọi Claude với prompt được cung cấp |
| 5 | Client | Trả text sinh ra về cho Server |
| 6 | Server | Dùng text đó trong kết quả trả về |
Client vs Server: ai giữ bộ não, ai gọi Claude
Điểm dễ nhầm nhất của sampling là hướng gọi. Chìa khóa: trong MCP, Client là bên có "bộ não" (kết nối tới model Claude) và credit token — ví dụ Claude Code, Claude Desktop, hay app tôi tự viết. Server chỉ là kho công cụ (fetch web, query DB, gọi API), theo thiết kế không có bộ não. Vì vậy câu hỏi "ai gọi Claude?" luôn có một đáp án: bên có bộ não — tức Client. Sampling chỉ là tình huống Server không có bộ não nên nhờ Client gọi hộ.
Lợi ích của Sampling
- Giảm độ phức tạp phía Server — Server không cần tự tích hợp với mô hình ngôn ngữ.
- Chuyển gánh nặng chi phí — Client trả phí token, không phải Server.
- Không cần API key — Server không cần credential để gọi Claude.
- Lý tưởng cho public server — bạn không muốn một server công khai bị đội chi phí AI cho mỗi user.
Triển khai — code hai phía
Bật sampling cần code ở cả hai phía.
Server side
Trong hàm tool, dùng create_message để yêu cầu sinh văn bản:
@mcp.tool()
async def summarize(text_to_summarize: str, ctx: Context):
prompt = f"""
Please summarize the following text:
{text_to_summarize}
"""
result = await ctx.session.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(
type="text",
text=prompt
)
)
],
max_tokens=4000,
system_prompt="You are a helpful research assistant",
)
if result.content.type == "text":
return result.content.text
else:
raise ValueError("Sampling failed")
Lưu ý: create_message là hàm async (phải await) và nó không tự gọi Claude — nó gửi yêu cầu để Client gọi hộ.
Client side
Tạo một sampling callback để xử lý các yêu cầu từ Server:
async def sampling_callback(
context: RequestContext, params: CreateMessageRequestParams
):
# Gọi Claude bằng Anthropic SDK
text = await chat(params.messages)
return CreateMessageResult(
role="assistant",
model=model,
content=TextContent(type="text", text=text),
)
Rồi truyền callback này vào khi khởi tạo client session:
async with ClientSession(
read,
write,
sampling_callback=sampling_callback
) as session:
await session.initialize()
Chính sampling_callback mới là nơi thực sự gọi Claude — nó nằm ở phía Client, dùng credential của Client.
Khi nào nên dùng Sampling
Sampling có giá trị nhất khi xây public MCP server (server truy cập công khai). Bạn không muốn user ngẫu nhiên sinh văn bản không giới hạn bằng chi phí của mình. Với sampling, mỗi Client tự trả phí AI của họ mà vẫn tận dụng được chức năng của Server.
Về bản chất, kỹ thuật này dời độ phức tạp tích hợp AI từ Server sang Client — nơi thường đã sẵn có kết nối và credential cần thiết.
Áp dụng thực tế (góc nhìn Delivery)
Ở góc độ vận hành, sampling là một quyết định kiến trúc chi phí hơn là kỹ thuật thuần túy. Khi tôi cân nhắc mở một MCP Server dùng chung cho nhiều team hoặc công khai, câu hỏi đầu tiên không phải "làm sao gọi Claude" mà là "ai chịu hóa đơn token". Nếu Server tự cầm API key, một tool sinh văn bản bị gọi lặp trong vòng lặp agent có thể thổi bay ngân sách mà chủ Server không kiểm soát được đầu vào. Sampling đẩy cả chi phí lẫn quyền chọn mô hình về phía Client — bên thật sự phát sinh nhu cầu — nên nó vừa an toàn ngân sách, vừa giảm bề mặt bảo mật (Server không giữ secret). Đổi lại, Server mất quyền quyết định model/tham số sinh, nên với internal tool dùng nội bộ có kiểm soát, gọi trực tiếp đôi khi vẫn đơn giản và đủ.
Logging và Progress notifications
Logging và progress notifications rất dễ triển khai nhưng tạo khác biệt lớn về trải nghiệm khi làm việc với MCP server. Chúng giúp người dùng biết đang có gì xảy ra trong những thao tác chạy lâu, thay vì phải phỏng đoán xem tool có bị treo hay không.
Bài toán
Khi Claude gọi một tool tốn thời gian — như research một chủ đề hay xử lý dữ liệu — người dùng thường không thấy gì cho tới khi thao tác kết thúc. Điều này gây khó chịu: họ không biết tool đang chạy hay đã "đứng hình". Bật logging và progress notifications thì người dùng nhận phản hồi real-time: log trạng thái, thanh tiến độ, thông báo chi tiết ngay trong lúc chạy.
Cách hoạt động — qua Context
Trong Python MCP SDK, cả hai đi qua tham số Context được tự động truyền vào hàm tool. Object này cho ta các method để "nói chuyện ngược" về Client trong lúc thực thi:
context.info()— gửi log message về Client.context.report_progress(current, total)— cập nhật tiến độ (giá trị hiện tại / tổng).
@mcp.tool(name="research", description="Research a given topic")
async def research(
topic: str = Field(description="Topic to research"),
*,
context: Context
):
await context.info("About to do research...")
await context.report_progress(20, 100)
sources = await do_research(topic)
await context.info("Writing report...")
await context.report_progress(70, 100)
results = await generate_report(sources)
return results
(Lưu ý dấu *, trong chữ ký hàm: context là keyword-only argument — luôn truyền theo tên.)
Phía Client — hai callback, hai chỗ gắn khác nhau
Server chỉ phát thông báo; Client toàn quyền quyết định trình bày thế nào. Đây là điểm dễ nhầm nhất và hay ra thi — hai callback gắn vào hai nơi khác nhau:
| Callback | Nhận gì | Gắn ở đâu |
|---|---|---|
logging_callback |
LoggingMessageNotificationParams (đọc params.data) |
ClientSession(...) — cấp session (1 lần) |
progress_callback |
(progress, total, message) |
session.call_tool(...) — cấp từng tool call |
async def logging_callback(params: LoggingMessageNotificationParams):
print(params.data)
async def print_progress_callback(
progress: float, total: float | None, message: str | None
):
if total is not None:
percentage = (progress / total) * 100
print(f"Progress: {progress}/{total} ({percentage:.1f}%)")
else:
print(f"Progress: {progress}")
async def run():
async with stdio_client(server_params) as (read, write):
async with ClientSession(
read, write, logging_callback=logging_callback # ← cấp session
) as session:
await session.initialize()
await session.call_tool(
name="add",
arguments={"a": 1, "b": 3},
progress_callback=print_progress_callback, # ← cấp từng call
)
Cách trình bày tùy loại ứng dụng
- CLI — chỉ cần
printmessage + tiến độ ra terminal. - Web — dùng WebSocket, server-sent events (SSE) hoặc polling để đẩy update lên browser.
- Desktop — cập nhật progress bar / status display trong UI.
Cả hai tính năng hoàn toàn tùy chọn: có thể phớt lờ, chỉ hiện một loại, hoặc trình bày kiểu gì tùy ứng dụng. Chúng thuần là enhancement trải nghiệm giúp người dùng hiểu chuyện gì đang diễn ra trong thao tác chạy lâu — không thay đổi kết quả của tool.
Áp dụng thực tế (góc nhìn Delivery)
So với sampling (quyết định kiến trúc chi phí), logging/progress là quyết định về niềm tin của người dùng. Với tool đồng bộ vài giây thì bỏ qua cũng được; nhưng với tool agentic chạy hàng chục giây tới vài phút (research, batch xử lý), im lặng = người dùng nghĩ hỏng rồi kill tiến trình hoặc bấm lại — vừa hỏng trải nghiệm vừa tốn compute lặp. Tôi coi report_progress ở các mốc có ý nghĩa (không phải mỗi vòng lặp — spam log cũng hại) như một hợp đồng ngầm với người dùng: "vẫn đang chạy, còn ngần này nữa". Vì đây là kênh một chiều Server→Client và Client có quyền lờ đi, thêm nó gần như không rủi ro — nên với tool chạy lâu, mặc định là nên có.
Roots — cấp quyền truy cập file + ngữ cảnh để tìm file
Roots là cách cấp cho MCP Server quyền truy cập một số file và thư mục cụ thể trên máy local. Có thể hình dung như một hệ thống phân quyền nói: "MCP server, mày được đụng vào những file này" — nhưng nó làm nhiều hơn chỉ cấp quyền: nó còn cho Claude ngữ cảnh để tự tìm file.
Bài toán Roots giải quyết
Không có roots sẽ vướng một vấn đề rất phổ biến. Giả sử có một MCP server với tool convert video: nhận vào một đường dẫn file rồi đổi MP4 sang MOV.
Khi người dùng bảo Claude "convert biking.mp4 to mov", Claude chỉ gọi tool với đúng cái tên file. Vấn đề: Claude không có cách nào dò khắp filesystem để biết file đó thực sự nằm đâu. Máy có thể phức tạp, file nằm rải rác nhiều thư mục. Người dùng biết biking.mp4 ở folder Movies, nhưng Claude thì không.
Có thể chữa bằng cách bắt user luôn nhập đường dẫn đầy đủ — nhưng rất kém thân thiện. Chẳng ai muốn gõ full path mỗi lần.
Roots hoạt động (4 bước, tự động)
Người dùng vẫn chỉ cần nói "convert biking.mp4" — Claude tự lo phần còn lại:
| # | Claude làm gì |
|---|---|
| 1 | Người dùng yêu cầu đổi một file video |
| 2 | Gọi list_roots để xem được phép truy cập những thư mục nào |
| 3 | Gọi read_dir trên các thư mục đó để dò tìm file |
| 4 | Tìm thấy → gọi tool convert với đường dẫn đầy đủ |
Bảo mật và ranh giới
Roots cũng tạo bảo mật bằng cách giới hạn truy cập. Nếu chỉ cấp quyền vào folder Desktop, MCP server không thể đụng file ở nơi khác như Documents hay Downloads. Khi Claude thử truy cập file ngoài các root đã duyệt, nó nhận lỗi và có thể báo cho người dùng biết file đó không truy cập được với cấu hình server hiện tại.
⚠️ Điểm dễ nhầm nhất — SDK KHÔNG tự thực thi
MCP SDK không tự động ép ràng buộc root — lập trình viên phải tự triển khai. Mẫu điển hình là viết một hàm helper is_path_allowed():
- Nhận đường dẫn file được yêu cầu.
- Lấy danh sách roots đã duyệt.
- Kiểm tra path có nằm trong một root nào không.
- Trả về
True/Falsecho quyền truy cập.
Rồi gọi hàm này trong mọi tool có đụng tới file/thư mục, trước khi thực hiện thao tác thật. Nói cách khác: list_roots là bản đồ, còn is_path_allowed() là bảo vệ đứng cửa — có bản đồ mà quên bảo vệ thì ranh giới chỉ là hình thức.
Lợi ích chính
- Thân thiện — người dùng không cần cung cấp đường dẫn đầy đủ.
- Tìm kiếm có tiêu điểm — Claude chỉ lục trong thư mục đã duyệt → tìm file nhanh hơn.
- Bảo mật — chặn truy cập nhầm các file nhạy cảm ngoài vùng cho phép.
- Linh hoạt — có thể cấp roots qua tool, hoặc inject thẳng vào prompt.
Áp dụng thực tế (góc nhìn Delivery)
Điểm tôi luôn nhấn với team: roots là "security theater" nếu quên is_path_allowed(). Việc khai báo root chỉ cho Claude biết nên tìm ở đâu — nó không tự chặn một tool đọc /etc/passwd hay C:\Users\...\.ssh. Ranh giới thật chỉ tồn tại khi mọi tool đụng file đều gọi hàm kiểm tra trước, và phải chống cả mẹo path traversal (../../) khi so khớp. Vì vậy tôi coi roots gồm hai nửa: nửa "trải nghiệm" (SDK lo — list_roots/read_dir) và nửa "an toàn" (dev tự lo — is_path_allowed). Nhớ đúng ranh giới trách nhiệm này là chỗ tách một MCP server đồ chơi khỏi một cái đủ an toàn để cắm vào máy thật.
Tổng hợp 3 tính năng nâng cao
Ba tính năng của bài này giải ba bài toán khác nhau, dễ lẫn — bảng dưới gom lại để ôn nhanh:
| Sampling | Logging & Progress | Roots | |
|---|---|---|---|
| Giải bài toán | Tool cần AI nhưng Server không nên tự nuôi model | Tool chạy lâu, user tưởng bị treo | Claude cần tìm file nhưng không biết ở đâu + cần ranh giới |
| Hướng đi | Server nhờ → Client gọi model | Server báo → Client hiển thị | Client hỏi → Server khai báo folder cho phép |
| Hàm phía Server | create_message (qua ctx.session) |
context.info() · context.report_progress() |
(tự viết) is_path_allowed() trong mỗi tool |
| Phía Client | sampling_callback (ở ClientSession) |
logging_callback (ở ClientSession) + progress_callback (ở call_tool) |
list_roots · read_dir |
| Bản chất | Chi phí & credential (Client trả token) | Trải nghiệm (trấn an user) | Bảo mật & ngữ cảnh (ranh giới + bản đồ) |
| Bắt buộc? | Tùy — hợp nhất cho public server | Optional, thuần UX | Ranh giới phải tự code (SDK không ép) |
| Nhớ 1 câu | "mượn bộ não" | "gắn đèn báo" | "chìa khoá + bản đồ" |
Message types — JSON-RPC và hai nhóm message
MCP xử lý giao tiếp giữa client và server hoàn toàn bằng JSON messages (chuẩn JSON-RPC 2.0). Hiểu các loại message rất quan trọng khi làm việc với MCP, đặc biệt khi đụng tới các transport khác nhau như streamable HTTP transport.
Định dạng message
Mỗi loại message phục vụ một mục đích cụ thể — gọi tool, liệt kê resource, hay báo sự kiện hệ thống. Ví dụ điển hình: khi Claude cần gọi một tool, client gửi Call Tool Request; server xử lý, chạy tool, rồi trả về Call Tool Result chứa output.
// Call Tool Request (client → server)
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "add", "arguments": { "a": 5, "b": 3 } } }
// Call Tool Result (server → client)
{ "jsonrpc": "2.0", "id": 1,
"result": { "content": [{ "type": "text", "text": "8" }], "isError": false } }
Trường id ghép "thư hỏi" với đúng "thư trả lời" của nó (như số vận đơn) — gửi nhiều request song song vẫn biết cái nào ứng với cái nào. Result còn có isError để báo tool chạy lỗi hay không.
Hai nhóm message
| Nhóm | Đặc điểm | Ví dụ |
|---|---|---|
| Request–Result | Luôn đi theo cặp, id khớp nhau; gửi request là chờ result |
Call Tool · List Prompts · Read Resource · Initialize (Request → Result) |
| Notification | Một chiều, báo sự kiện, KHÔNG cần phản hồi | Progress · Logging Message · Tool List Changed · Resource Updated |
Lưu ý liên hệ: Logging và Progress ở các mục trên chính là hai thành viên của nhóm Notification — nay đã rõ chúng thuộc "họ" nào.
Spec ≠ SDK
Danh sách đầy đủ các loại message được định nghĩa trong MCP Specification chính thức trên GitHub (github.com/modelcontextprotocol/modelcontextprotocol) — đây là nguồn chuẩn (authoritative) mô tả MCP nên hoạt động thế nào. Spec tách riêng khỏi các repo SDK (Python, TypeScript…). Các message types được viết bằng TypeScript cho tiện — không phải để chạy như code TS, mà vì TypeScript là cách rõ ràng để mô tả cấu trúc và kiểu dữ liệu.
Client vs Server messages — và tính hai chiều
Spec tổ chức message theo ai gửi: Client messages (request client gửi cho server như tool call, cùng các notification client gửi) và Server messages (request server gửi cho client, cùng notification server broadcast).
Điểm cốt lõi: MCP là giao thức hai chiều (bidirectional) — cả client lẫn server đều có thể chủ động khởi tạo giao tiếp, không phải lúc nào client cũng hỏi trước. Điều này đặc biệt quan trọng khi chọn transport: một số transport như streamable HTTP có giới hạn loại message nào được đi theo chiều nào. Biết rằng server-gửi-client là chuyện có thật giúp chọn đúng transport để notification từ server không bị "chết".
Transport và stdio
MCP client và server trao đổi message JSON — nhưng những message đó thật sự được truyền đi bằng cách nào? Kênh giao tiếp đó gọi là transport. Có nhiều cách hiện thực: HTTP request, WebSocket, thậm chí viết JSON lên bưu thiếp (dĩ nhiên cách cuối không dành cho production).
Stdio transport
Khi mới phát triển một MCP server hoặc client, transport hay dùng nhất là stdio transport. Cách làm rất thẳng: client khởi chạy MCP server như một subprocess và giao tiếp qua luồng vào/ra chuẩn:
- Client gửi message tới server qua
stdincủa server. - Server trả lời bằng cách ghi ra
stdout. - Server hoặc client đều có thể gửi message bất cứ lúc nào.
- Chỉ hoạt động khi client và server chạy trên cùng một máy.
Bốn tình huống giao tiếp — chỉ với hai ống
Với bất kỳ transport nào cũng phải xử lý bốn kiểu giao tiếp. Điểm tinh tế của stdio: ống được chọn theo hướng đi, không theo vai trò của message — cứ đi vào server thì qua stdin, đi ra khỏi server thì qua stdout.
| # | Tình huống | Kênh |
|---|---|---|
| 1 | Client gửi request → Server | Client ghi vào stdin |
| 2 | Server gửi response → Client | Server ghi ra stdout |
| 3 | Server gửi request → Client | Server ghi ra stdout |
| 4 | Client gửi response → Server | Client ghi vào stdin |
Vẻ đẹp của stdio nằm ở sự đơn giản: chỉ hai kênh, và bên nào cũng có thể chủ động mở lời bất cứ lúc nào.
Test server ngay trên terminal
Có thể kiểm thử một MCP server thẳng từ terminal, không cần viết client riêng. Khi chạy uv run server.py, server lắng nghe stdin và ghi phản hồi ra stdout — nghĩa là có thể dán thẳng message JSON vào terminal và thấy phản hồi ngay. Rất tiện để debug.
Trình tự khởi tạo kết nối (bắt tay 3 message)
Mọi kết nối MCP bắt buộc mở đầu bằng đúng ba message theo thứ tự:
| # | Message | Vai trò |
|---|---|---|
| 1 | Initialize Request | Client gửi trước tiên |
| 2 | Initialize Result | Server trả lời, khai báo capabilities |
| 3 | Initialized Notification | Client xác nhận — không chờ phản hồi |
Chỉ sau khi hoàn tất bắt tay này mới được gửi các request khác như tool call hay list prompts.
Ai khởi xướng message nào
Bảng dưới gom lại hướng đi của các message — và nó khớp lại toàn bộ các mục trên:
| Bên khởi xướng | Request–Result | Notification |
|---|---|---|
| Client → | Call Tool · List Prompts · Read Resource · Initialize |
Initialized · Cancelled |
| Server → | Create Message (= Sampling) · List Roots (= Roots) |
Progress · Logging |
Nhìn dòng Server sẽ thấy lý do MCP phải là giao thức hai chiều: Sampling (Create Message) và Roots (List Roots) đều là request do server gửi ngược cho client. Nếu transport không cho server chủ động mở lời, hai tính năng này không hoạt động được.
Vì sao stdio quan trọng
Hiểu stdio là then chốt vì nó đại diện cho trường hợp "lý tưởng": giao tiếp hai chiều diễn ra mượt mà, không ràng buộc. Khi chuyển sang transport khác như HTTP, sẽ gặp giới hạn: server không phải lúc nào cũng khởi tạo được request tới client. Stdio là mốc chuẩn (baseline) để hiểu một cuộc giao tiếp MCP đầy đủ trông ra sao, trước khi đối mặt ràng buộc của các transport khác.
Với phát triển và kiểm thử, stdio là hoàn hảo. Với production khi client và server cần chạy trên các máy khác nhau, phải cân nhắc transport khác cùng những đánh đổi riêng của chúng.
Streamable HTTP transport
Streamable HTTP transport cho phép MCP client kết nối tới server được host từ xa qua HTTP connection. Khác stdio vốn đòi client và server cùng một máy, transport này mở ra khả năng làm public MCP server mà ai cũng truy cập được (ví dụ host tại https://mcp-server.com/mcp).
Nhưng có một cảnh báo quan trọng: vài cấu hình có thể giới hạn đáng kể chức năng của MCP server. Nếu ứng dụng chạy hoàn hảo với stdio ở local nhưng vỡ khi deploy bằng HTTP transport, đây nhiều khả năng là thủ phạm.
Hai setting cần biết
| Setting | Mặc định | Vai trò |
|---|---|---|
stateless_http |
false |
Điều khiển việc quản lý trạng thái kết nối |
json_response |
false |
Điều khiển việc xử lý định dạng response |
Mặc định cả hai đều false, nhưng một số kịch bản deploy buộc phải bật lên true. Khi bật, chúng có thể phá các chức năng lõi: progress notification, logging, và các request do server khởi xướng.
Gốc rễ: HTTP vốn không cho server mở lời
Để hiểu vì sao có giới hạn này, cần nhớ HTTP hoạt động ra sao. Với HTTP tiêu chuẩn:
- Client dễ dàng khởi tạo request tới server — vì server có URL đã biết.
- Server dễ dàng phản hồi các request đó.
- Server không thể dễ dàng khởi tạo request tới client — vì client không có URL đã biết.
- Kiểu "client phản hồi ngược lại server" trở nên rắc rối.
Điều này đúng với mọi HTTP request, không riêng gì MCP. Nói cách khác, HTTP thuần chỉ phục vụ tốt 2 trong 4 tình huống giao tiếp mà MCP cần.
Những message bị ảnh hưởng
Giới hạn trên tác động đúng vào các pattern sau — và đó chính là những tính năng đã học ở các mục trên:
| Nhóm | Message | Tương ứng tính năng |
|---|---|---|
| Request do Server khởi xướng | Create Message · List Roots |
Sampling · Roots |
| Notification | Progress · Logging · Initialized · Cancelled |
Logging & Progress |
Đây đúng là những thứ vỡ khi bật các setting HTTP hạn chế: thanh tiến độ biến mất, logging ngừng hoạt động, và sampling request do server khởi xướng thất bại.
Giải pháp: lách bằng Server-Sent Events (SSE)
StreamableHTTP giải bài toán này bằng một workaround khéo dùng Server-Sent Events (SSE). Ý tưởng cốt lõi: server không biết địa chỉ client, vậy thì để client tự gọi tới và giữ kết nối mở — server chỉ việc "nói xuống" đường dây có sẵn đó.
Bước 1 — khởi tạo và nhận mcp-session-id. Kết nối bắt đầu như mọi kết nối MCP, nhưng có thêm một chi tiết:
| # | Message | Điểm mới |
|---|---|---|
| 1 | Initialize Request |
Client gửi |
| 2 | Initialize Result |
Server trả về kèm header mcp-session-id (vd 7089d4160bbb4d) |
| 3 | Initialized Notification |
Client gửi lại kèm session ID |
mcp-session-id là then chốt: nó định danh duy nhất client và bắt buộc phải có trong mọi request về sau.
Bước 2 — mở đường SSE. Sau khi khởi tạo, client gửi một GET request để thiết lập một SSE connection. Việc này tạo ra một HTTP response sống lâu (long-lived) mà server có thể dùng để stream message về client bất cứ lúc nào. Chính SSE connection này là chìa khoá mở lại chiều server→client: từ đây server gửi được request, notification và các message khác qua kênh bền vững này.
Mô hình hai SSE connection
Khi client gọi tool, mọi thứ phức tạp hơn: hệ thống tạo hai SSE connection riêng biệt.
| Kết nối | Mở khi | Dùng cho | Đóng khi |
|---|---|---|---|
| Primary SSE | Client GET sau khi init |
Request do server khởi xướng | Mở vô hạn (indefinitely) |
| Tool-specific SSE | Mỗi tool call (POST) |
Message liên quan tool call đó | Tự đóng khi Call Tool Result được gửi |
Định tuyến message — điểm rất dễ nhầm
Các loại message được định tuyến qua những kết nối khác nhau:
| Message | Đi qua |
|---|---|
Request do server khởi xướng (Create Message, List Roots) |
Primary SSE |
| Progress notification | Primary SSE |
| Logging message | Tool-specific SSE |
| Call Tool Result | Tool-specific SSE |
⚠️ Bẫy đáng nhớ: Progress đi qua Primary, còn Logging lại đi qua Tool-specific — dù cả hai đều phát ra trong lúc tool đang chạy.
Hai flag phá cơ chế lách
Đặt stateless_http=True hoặc json_response=True phá vỡ cơ chế SSE workaround này. Có kịch bản buộc phải bật, nhưng bật là giới hạn toàn bộ chức năng MCP phụ thuộc giao tiếp server→client. Về bản chất, đó là bảo transport hoạt động bên trong ràng buộc của HTTP thay vì lách qua chúng.
mcp = FastMCP(
"mcp-server",
stateless_http=True,
json_response=True,
)
Vì sao cần stateless HTTP — bài toán scale ngang
Giả sử một MCP server trở nên phổ biến. Ban đầu chỉ vài client nối tới một instance. Khi lớn lên tới hàng ngàn client, một instance không gánh nổi. Giải pháp kinh điển là scale ngang (horizontal scaling): chạy nhiều instance sau một load balancer.
Nhưng đây là chỗ rắc rối. Client MCP cần hai kết nối riêng biệt: một GET SSE để nhận request từ server, và các POST để gọi tool cùng nhận kết quả. Load balancer có thể định tuyến hai kết nối này tới hai instance khác nhau. Nếu tool cần dùng Claude (qua sampling), instance đang xử lý POST phải phối hợp với instance đang giữ GET SSE — tạo ra một bài toán điều phối phức tạp giữa các server.
Stateless HTTP giải quyết thế nào — và cái giá
Đặt stateless_http=True loại bỏ bài toán điều phối, nhưng kèm đánh đổi đáng kể. Khi bật stateless HTTP:
- Client không nhận session ID — server không thể theo dõi từng client.
- Không còn request server→client — đường GET SSE trở nên vô dụng (server không ghép nổi kênh response đó với request đến nào).
- Không sampling — không dùng được Claude hay model AI khác.
- Không progress report — không gửi được cập nhật tiến độ khi chạy lâu.
- Không subscriptions — không báo được resource update cho client.
Đổi lại, có một lợi ích: không còn bắt buộc khởi tạo (initialization) — client gọi request thẳng, khỏi quá trình bắt tay ban đầu.
json_response — chỉ tắt streaming
Flag json_response=True đơn giản hơn nhiều: nó chỉ tắt streaming cho response của POST request. Thay vì nhận nhiều message SSE trong lúc tool chạy, ta chỉ nhận kết quả cuối dạng JSON thuần. Khi tắt streaming: không có message tiến độ trung gian, không có log trong lúc thực thi, chỉ còn kết quả cuối của tool.
Bật cả hai flag thì sao — bảng 4 tổ hợp
Hai flag này không loại trừ nhau: chúng là hai công tắc độc lập, điều khiển hai thứ khác nhau — stateless_http cắt đường GET SSE (kênh server mở lời), còn json_response cắt streaming trên POST response. Chính ví dụ của khoá cũng bật cả hai cùng lúc.
stateless_http |
json_response |
Kết quả |
|---|---|---|
false |
false |
Mặc định — MCP đầy đủ. Có session ID, GET SSE, sampling, roots, progress, logging, subscriptions, và POST response được stream. |
true |
false |
Mất: session ID · request server→client · sampling · progress · subscriptions. Được: khỏi initialization. POST response vẫn stream. |
false |
true |
Vẫn còn session ID và đường GET SSE (nên sampling/roots vẫn dùng được), nhưng POST response không stream → mất log lúc thực thi và message tiến độ trung gian, chỉ còn kết quả cuối. |
true |
true |
Hạn chế nhất — MCP tụt xuống mức request/response JSON thuần, không trạng thái: POST một phát, nhận một JSON cuối. Không session, không init, không chiều server→client, không stream. Scale tối đa, chức năng tối thiểu. |
Cách nhớ: stateless_http giết chiều server nói xuống; json_response giết dòng chảy của câu trả lời. Bật cả hai = server chỉ còn biết trả lời khi được hỏi, và trả đúng một lần.
Lưu ý đọc kỹ: khoá mô tả
json_responselà "chỉ tắt streaming cho response của POST". Việc sampling/roots còn sống ở tổ hợpfalse/truelà suy ra từ chỗ chúng đi qua Primary SSE (mở bằngGET) chứ không qua POST stream — khoá không nói thẳng ý này, nên khi thi cứ bám định nghĩa gốc của từng flag.
Stdio vs Streamable HTTP — bảng phân biệt
| Tiêu chí | stdio | Streamable HTTP |
|---|---|---|
| Kênh truyền | stdin (vào server) · stdout (ra server) |
POST (client→server) · GET SSE (server→client) |
| Client & server ở đâu | Bắt buộc cùng một máy | Khác máy — server host từ xa (public được) |
| Server chạy thế nào | Client spawn làm subprocess | Server chạy sẵn tại URL đã biết |
| Server mở lời được? | Được, tự nhiên — ống luôn mở hai chiều | Không tự nhiên — phải lách bằng GET SSE |
| Định danh phiên | Không cần | mcp-session-id bắt buộc mọi request sau init |
| Số kết nối | 2 ống cố định | Primary SSE + Tool-specific SSE (mỗi tool call) |
| Sampling · Roots · Progress | ✅ chạy tự nhiên | ✅ chỉ khi giữ được SSE (mất nếu stateless_http=True) |
| Scale ngang / load balancer | Không đặt ra (1 client ↔ 1 subprocess) | Vấn đề lớn → chính là lý do có stateless_http |
| Bắt tay init | Bắt buộc | Bắt buộc — trừ khi stateless_http=True |
| Dùng khi | Dev / test / local | Production / public server |
Dev vs Production
Nếu phát triển local bằng stdio transport nhưng dự định deploy bằng HTTP transport, hãy test bằng đúng transport sẽ dùng ở production. Khác biệt hành vi giữa chế độ stateful và stateless có thể rất lớn, và bắt lỗi lúc phát triển vẫn hơn phát hiện sau khi deploy. Đây cũng chính là lời giải cho triệu chứng "chạy ngon ở local, lên prod thì vỡ" nêu ở trên.
Các flag này thay đổi tận gốc cách MCP server vận hành, nên phải chọn dựa trên yêu cầu scale và yêu cầu chức năng cụ thể của mình.
Cập nhật 07/2026: hướng đi mà khoá mô tả qua flag
stateless_httpđã thành mặc định của chính giao thức — spec MCP 2026-07-28 bỏ handshake và session ID bắt buộc. Sau khi học xong khoá này, tôi phân tích thay đổi đó và soạn checklist migrate cho server author ở bài MCP chuyển stateless (spec 2026-07-28).
Điểm cốt lõi cần nhớ
StreamableHTTP phức tạp hơn các transport MCP khác vì phải lách giới hạn của HTTP. Workaround dựa trên SSE cho phép chạy đầy đủ chức năng MCP qua HTTP, nhưng hiểu mô hình hai kết nối là điều then chốt để debug và tối ưu. Khi build ứng dụng MCP với StreamableHTTP, nhớ rằng session ID là bắt buộc cho mọi request sau khởi tạo, và hệ thống tự động quản lý nhiều SSE connection để phục vụ các loại giao tiếp server→client khác nhau.
Áp dụng thực tế (góc nhìn Delivery)
Hiểu các giới hạn này giúp ra quyết định có cơ sở về: chọn transport nào cho từng kịch bản deploy; thiết kế server sao cho vẫn hoạt động tử tế trong ràng buộc HTTP; và khi nào chấp nhận giảm chức năng để đổi lấy lợi ích của việc host từ xa.
Điểm mấu chốt là biết những hạn chế này tồn tại và quy hoạch kiến trúc MCP server tương ứng. Nếu ứng dụng phụ thuộc nặng vào server-initiated request hoặc notification real-time, có thể phải cân nhắc lại lựa chọn transport hoặc hiện thực các pattern giao tiếp thay thế. Với tôi, đây là câu hỏi cần đặt ở giai đoạn thiết kế, không phải lúc deploy: chọn HTTP để đi xa là một đánh đổi có thật, không phải một chi tiết cấu hình.
Từ khoá cần thuộc
🔴 Core: Sampling · create_message (Server side, qua ctx.session) · sampling_callback (Client side, truyền vào ClientSession) · "Client trả phí token, Server không cần API key" · dùng cho public MCP server · context.info() (log) · context.report_progress(current, total) (tiến độ) · logging_callback gắn ở ClientSession, progress_callback gắn ở call_tool · Roots · list_roots · read_dir · is_path_allowed() · SDK KHÔNG tự enforce roots — dev tự implement.
🟡 Important: SamplingMessage · CreateMessageResult · CreateMessageRequestParams · max_tokens · system_prompt · luồng 6 bước Server↔Client · Context (tham số keyword-only) · LoggingMessageNotificationParams (params.data) · logging/progress là optional, chỉ UX · roots = permission + context (2 vai) · cấp root hẹp = ranh giới bảo mật (ngoài root → lỗi).
🟢 Good-to-know: TextContent / type == "text" · RequestContext · trade-off sampling vs Server gọi trực tiếp (internal vs public) · cách trình bày CLI / Web (SSE, WebSocket) / Desktop · total có thể là None · cấp roots qua tool hoặc inject vào prompt · path traversal (../) khi so khớp root · message types viết bằng TypeScript (mô tả kiểu, không chạy) · method "tools/call" · streamable HTTP transport giới hạn chiều message.
Message types (bổ sung Core/Important): 🔴 JSON-RPC 2.0 · hai nhóm Request–Result vs Notification · MCP là bidirectional (client & server đều gửi được). 🟡 id ghép request↔result · isError · các Notification: Progress / Logging Message / Tool List Changed / Resource Updated · MCP Specification repo tách khỏi SDK repo.
Streamable HTTP (bổ sung): 🔴 streamable HTTP = nối tới server host từ xa (public server) · workaround = Server-Sent Events (SSE) · mcp-session-id (server trả trong Initialize Result, bắt buộc kèm mọi request sau đó) · client mở SSE bằng GET → response long-lived cho server stream về · mô hình 2 kết nối: Primary SSE (mở vô hạn, cho server-initiated request + Progress) vs Tool-specific SSE (mỗi tool call, mang Logging + Call Tool Result, tự đóng khi result gửi xong) · stateless_http và json_response (mặc định false; bật true → phá SSE workaround) · lý do gốc: client không có URL đã biết nên server không mở lời được. 🟡 triệu chứng: stdio local chạy ngon, deploy HTTP thì vỡ · bẫy định tuyến: Progress → Primary, Logging → Tool-specific · bật 2 setting = "chơi trong ràng buộc HTTP thay vì lách" · HTTP thuần chỉ ngon 2/4 tình huống. 🟢 giới hạn này đúng với mọi HTTP, không riêng MCP · hệ thống tự động quản lý nhiều SSE connection.
stateless_http / json_response (bổ sung): 🔴 lý do tồn tại = scale ngang + load balancer đẩy GET SSE và POST sang 2 instance khác nhau ⇒ bài toán điều phối · stateless_http=True tắt: session ID · request server→client · sampling · progress · subscriptions; được: khỏi initialization · json_response=True chỉ tắt streaming của POST response ⇒ mất progress trung gian + log, chỉ còn kết quả cuối (JSON thuần). 🟡 FastMCP("mcp-server", stateless_http=True, json_response=True) · test bằng đúng transport sẽ deploy (stateful vs stateless khác nhau rất lớn). 🟢 không session id ⇒ server không ghép nổi đường GET SSE với request đến.
Transport & stdio (bổ sung): 🔴 transport = kênh truyền message · stdio: client chạy server như subprocess, client ghi stdin · server ghi stdout · chỉ cùng một máy · bắt tay 3 bước Initialize Request → Initialize Result → Initialized Notification (bắt buộc, trước mọi request khác) · request do server khởi xướng: Create Message (sampling) + List Roots (roots). 🟡 ống chọn theo hướng đi, không theo vai trò message (4 tình huống / 2 kênh) · Initialized Notification không chờ phản hồi · Cancelled Notification (client) · stdio = baseline lý tưởng, HTTP bị giới hạn server-khởi-xướng. 🟢 test server bằng cách dán JSON vào terminal (uv run server.py) · Initialize Result khai capabilities.
Muốn tự kiểm tra? Làm đề thi thử ở tab "Đề thi thử".
Nguồn: Model Context Protocol: Advanced Topics (Anthropic Academy) — Copyright Anthropic. Phần đề thi thử cho khoá này nằm ở tab "Đề thi thử".