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

10.1 · Các loại JSON message trong MCP

Bài viếtCơ bản → Trung c20 phút đọc

Tưởng tượng bạn đang ngồi trong một cuộc họp có phiên dịch viên. Bạn nói tiếng Việt, đối tác nói tiếng Nhật. Phiên dịch viên dịch qua lại. Nếu bạn muốn biết chính xác 2 bên đã nói gì, bạn cần đọc biên bản — nơi ghi lại từng câu, ai nói với ai, loại nào là câu hỏi (cần trả lời), loại nào là thông báo (không cần trả lời).

Sau bài này, bạn sẽ
  1. Giải thích được mọi MCP message đều là JSON và phân 2 loại chính: request-result và notification.
  2. Nhận diện được ai là phía gửi (client hay server) cho từng loại message cụ thể.
  3. Liệt kê được 4-6 message type phổ biến nhất trong production (CallTool, ListTools, Initialize, Progress, v.v.).
  4. Tự đọc được MCP specification trên GitHub và tìm ra định nghĩa của bất kỳ message nào.
  5. Hiểu được vì sao hiểu message types là điều kiện tiên quyết để debug transport issues ở các bài sau.

Mở đầu: Mọi cuộc hội thoại đều là bản chép lại

MCP hoạt động y hệt vậy.

Khi Claude Desktop (client) gọi một tool trên MCP server của bạn, về mặt kỹ thuật không có ma thuật nào xảy ra. Chỉ có các JSON message được gửi qua lại. Bạn có thể mở một terminal, paste một JSON vào stdin của server, và nhận về response — không cần Claude, không cần SDK phức tạp gì cả.

Bài này dạy bạn ngôn ngữ trao đổi đó. Một khi bạn hiểu MCP "nói" gì, bạn có thể:

  • Debug bằng terminal khi SDK log không đủ rõ.
  • Đọc MCP spec thay vì đoán mò.
  • Hiểu vì sao một số tính năng "không work trên HTTP" (hint: loại message nào không đi qua được HTTP đơn giản).

Bắt đầu từ format chuẩn nhé.

Mọi message đều là JSON-RPC 2.0

MCP không tự phát minh ra format mới. Nó dùng chuẩn JSON-RPC 2.0 — một spec đã có từ 2010, được dùng rộng rãi trong Ethereum, Lightning Network, Language Server Protocol (LSP), và nhiều hệ thống khác.

Ý tưởng chính: mọi message đều là JSON object với một số field tối thiểu cố định.

Ví dụ minh họa: Call Tool Request

Khi Claude muốn gọi tool add(a=2, b=3) của bạn, client gửi JSON sau:

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": { "a": 2, "b": 3 }
  }
}

Server xử lý xong, trả về:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [
      { "type": "text", "text": "5" }
    ]
  }
}

Chú ý id: 42 — đó là cái "ticket" để client biết response nào match với request nào khi chúng xen kẽ trên cùng một kênh.

Hai loại message — quan trọng nhất trong bài

MCP chia mọi message thành 2 nhóm lớn, và sự khác biệt giữa 2 nhóm này là gốc rễ của mọi thứ bạn sẽ học về transport sau này.

┌─────────────────────────────────────────────────────────┐
│                                                         │
│   NHÓM 1: REQUEST ↔ RESULT                              │
│   ────────────────────                                  │
│   • Đi theo cặp: 1 request → đúng 1 result              │
│   • Có field "id" để match request với result           │
│   • Bên gửi ĐỢI bên kia trả lời                         │
│                                                         │
│   NHÓM 2: NOTIFICATION                                  │
│   ────────────────────                                  │
│   • Đi một chiều — gửi xong là xong                     │
│   • KHÔNG có field "id"                                 │
│   • Bên gửi KHÔNG đợi phản hồi                          │
│                                                         │
└─────────────────────────────────────────────────────────┘

Nhớ 2 tiêu chí phân biệt: có id không và bên gửi có đợi response không.

Nhóm 1: Request ↔ Result

Đây là các cặp "gọi và trả lời":

Request (A gửi)Result (B trả về)Mục đích
initializeInitializeResultClient tự giới thiệu & khai báo capabilities
tools/listListToolsResultLấy danh sách tool server đang expose
tools/callCallToolResultGọi 1 tool cụ thể
resources/listListResourcesResultLấy danh sách resources
resources/readReadResourceResultĐọc nội dung 1 resource
prompts/listListPromptsResultLấy danh sách prompt templates
prompts/getGetPromptResultLấy nội dung 1 prompt cụ thể
sampling/createMessageCreateMessageResultServer xin client gọi model (bài 10.8)
roots/listListRootsResultServer xin client danh sách thư mục được phép (bài 10.10)

Để ý 2 dòng cuối: server là bên gửi request. Đây là điểm mấu chốt — MCP bidirectional, không chỉ client → server.

Nhóm 2: Notification

Các message "thông báo một chiều", không có response:

NotificationHướngMục đích
notifications/initializedClient → Server"Tôi đã sẵn sàng rồi, bắt đầu được"
notifications/progressServer → ClientBáo tiến độ cho tool chạy lâu
notifications/message (log)Server → ClientLog message để user xem
notifications/tools/list_changedServer → Client"Danh sách tool tôi vừa cập nhật, hãy refresh lại"
notifications/resources/updatedServer → ClientResource X đã thay đổi
notifications/cancelledCả hai chiềuHuỷ request đang chạy

Notification không có id. Bên gửi bắn đi và quên luôn — không đợi trả lời, không có callback. Đây là lý do chúng phù hợp với progress updates và logs: gửi nhiều, gửi nhanh, không block.

So sánh nhanh: Request vs Notification

Tiêu chíRequest-ResultNotification
Có field idCóKhông
Cần responseĐúng 1 responseKhông
Block senderSender đợiFire-and-forget
Ví dụ điển hìnhtools/call, initializeProgress, log, resource updated
Phù hợp vớiThao tác cần kết quảThông báo trạng thái
Gửi được trên HTTP đơn giản?Client → Server: dễ; Server → Client: khó (bài 10.3)Cả 2 chiều đều khó trên HTTP

Dòng cuối cùng sẽ quay lại ám ảnh bạn ở bài 10.3, 10.5, 10.6.

Spec ở đâu? Đọc TypeScript mà không chạy TypeScript

Toàn bộ message types được định nghĩa chính thức trong repo:

👉 github.com/modelcontextprotocol/specification

Điều quan trọng bạn cần biết:

  • Spec được viết bằng TypeScript không phải vì MCP chạy TypeScript, mà vì TypeScript là ngôn ngữ mô tả data structure rõ nhất cho developer ngoài ecosystem JS.
  • Các SDK (Python, TypeScript, Go, Rust, Java...) là repo riêng. Chúng implement theo spec, không định nghĩa spec.
  • Khi có conflict giữa SDK behavior và spec → spec thắng. SDK có thể có bug; spec là ground truth.

Mini-tour spec file

File trung tâm: schema.ts. Ví dụ cách spec định nghĩa CallToolRequest:

export interface CallToolRequest extends Request {
  method: "tools/call";
  params: {
    name: string;
    arguments?: { [key: string]: unknown };
  };
}

Đọc giống đọc Python class — field nào required, field nào optional, kiểu dữ liệu gì. Khi lỗi xảy ra trong production, bạn mở file này ra, find cái message tên lạ, biết ngay client đang expect gì.

Ai gửi cho ai — Client messages vs Server messages

Spec phân loại message theo bên gửi, không theo bên nhận. Điều này quan trọng khi bạn debug.

Client gửi đi (Client messages)

  • Tất cả request kiểu "get/list/call": initialize, tools/list, tools/call, resources/read, prompts/get
  • Notification: notifications/initialized, notifications/cancelled, thỉnh thoảng notifications/roots/list_changed
  • Response cho các server request: CreateMessageResult (đáp lại sampling), ListRootsResult (đáp lại roots request)

Server gửi đi (Server messages)

  • Kết quả của mọi request từ client (Result object)
  • Request của chính server: sampling/createMessage, roots/list
  • Notifications: progress, message (log), tools/list_changed, resources/updated, resources/list_changed, prompts/list_changed

Nhấn mạnh lại: server cũng gửi request được, không chỉ nhận. Nhóm này gồm sampling và roots — chính là 2 chủ đề chính của cụm bài sau.

Ví dụ thực chiến: Đọc raw message flow

Tình huống: user hỏi Claude "Dùng MCP research tool của tôi để research về Transformer architecture."

Đây là sequence raw message (rút gọn, bỏ các field metadata):

[1] Client → Server  (request, id=1)
    method: "initialize"
    params: { clientInfo: {name: "Claude Desktop", version: "..."} }

[2] Server → Client  (result, id=1)
    result: { serverInfo: {...}, capabilities: {tools: {}, sampling: {}} }

[3] Client → Server  (notification)
    method: "notifications/initialized"

[4] Client → Server  (request, id=2)
    method: "tools/list"

[5] Server → Client  (result, id=2)
    result: { tools: [{name: "research", description: "..."}] }

[6] Client → Server  (request, id=3)
    method: "tools/call"
    params: { name: "research", arguments: {topic: "Transformer"} }

[7] Server → Client  (notification)
    method: "notifications/progress"
    params: { progress: 20, total: 100, message: "Fetching Wikipedia..." }

[8] Server → Client  (request, id=4)     ← server gửi request!
    method: "sampling/createMessage"
    params: { messages: [...], maxTokens: 4000 }

[9] Client → Server  (result, id=4)
    result: { content: {type: "text", text: "Summary..."} }

[10] Server → Client  (notification)
    method: "notifications/progress"
    params: { progress: 90, total: 100, message: "Finalizing..." }

[11] Server → Client  (result, id=3)
    result: { content: [{type: "text", text: "...report..."}] }

Đọc kỹ. Bạn thấy:

  • Step 1-3: handshake 3 bước (initialize → InitializeResult → initialized notification). Đây là handshake bắt buộc của mọi session MCP.
  • Step 4-5: client hỏi "có tool gì", server list.
  • Step 6: client gọi tool research, id=3.
  • Step 7 & 10: server gửi progress notification trong khi đang xử lý. User thấy progress bar chạy.
  • Step 8-9: server gửi sampling/createMessage request tới client. Client tự gọi Claude (dùng API key của client), rồi trả CreateMessageResult về.
  • Step 11: sau khi có kết quả sampling, server hoàn thành research và trả CallToolResult cho client.

Quan sát quan trọng: Step 8 là server khởi tạo request. Trên stdio transport, không vấn đề gì. Trên HTTP thô, điều này cực kỳ khó — server không có URL của client để gọi ngược. Đây chính là lý do phải có SSE workaround trong Streamable HTTP (bài 10.4).

Ví dụ theo ngành — Vì sao message types matter

Product Engineer build MCP server cho internal tool

Pain: Team đang dùng MCP server nội bộ để Claude truy vấn feature flags. Vừa deploy lên Cloud Run, user report "tool calls đôi khi không trả về gì, phải reload session".

Giải pháp sau khi hiểu message types:

  • Debug bằng cách log mọi message với id → phát hiện một số CallToolResult bị mất khi stateless_http=True + nhiều instance sau load balancer.
  • Solution: switch sang stateful với session affinity, hoặc accept trade-off.
  • Kết quả: thời gian debug 3 ngày → 30 phút (nhờ đọc raw message thay vì mò qua SDK log).

Finance Analyst dùng MCP server để query warehouse

Pain: Tool query_warehouse chạy 90 giây cho query lớn, user tưởng Claude bị treo, hủy và retry liên tục, dẫn tới load spike.

Giải pháp:

  • Thêm notifications/progress với step: "Parsing SQL... (10%)", "Executing... (50%)", "Fetching rows... (90%)".
  • User thấy progress bar, không hủy nữa.
  • Kết quả: Retry rate 35% → 2%, không cần tăng concurrency limit.

DX Engineer duy trì MCP SDK của startup

Pain: User báo "prompt templates không refresh khi tôi edit server code". Team đang debug 2 tuần.

Giải pháp sau khi đọc spec:

  • Phát hiện server không gửi notifications/prompts/list_changed sau khi reload internal state.
  • Fix: 1 dòng await ctx.session.send_notification(...) trong reload handler.
  • Kết quả: user không cần restart session. Support tickets liên quan giảm 80%.

Anti-patterns — Những sai lầm cần tránh

Nhầm notification với request

Hiện tượng
Bạn viết server gửi notifications/progress và rồi await đợi response.
Tại sao sai
Notification không có response. Code bạn sẽ treo vô tận hoặc timeout.
Cách đúng
Trong SDK Python, dùng ctx.report_progress(...) — nó biết đây là notification nên không block. Đừng tự tay build JSON rồi đợi.

Dùng id trùng nhau cho 2 request đồng thời

Hiện tượng
Bạn implement client thô, gửi 2 tools/call liên tiếp với id=1.
Tại sao sai
Khi nhận 2 result cùng id=1, bạn không biết result nào của request nào. Gần như chắc chắn sai data cho user.
Cách đúng
Sinh id unique (UUID, counter atomic) cho mỗi request trong session. SDK tự lo việc này, nhưng nếu build raw thì phải cẩn thận.

Assume mọi transport đều hỗ trợ server → client request

Hiện tượng
Bạn viết server dùng sampling, test trên stdio OK, deploy HTTP với stateless_http=True, sampling fail im lặng.
Tại sao sai
stateless_http cắt đứt đường server → client. Sampling request của server không có cách nào đến client.
Cách đúng
Test với transport production sẽ dùng. Bài 10.5 sẽ list đầy đủ flag nào cắt đứt khả năng nào.

Coi message types là chi tiết kỹ thuật, không đọc spec

Hiện tượng
"Tôi dùng SDK rồi, cần gì đọc spec."
Tại sao sai
Khi SDK có bug hoặc version mới thêm message type, bạn sẽ bế tắc nếu không quen đọc spec.
Cách đúng
Đánh dấu trang schema.ts của spec. Mỗi khi có message type lạ, mở ra đọc 30 giây.

Mẹo nâng cao

Mẹo 1: Bật verbose log khi debug

Cách đơn giản nhất để xem raw JSON message là dùng MCP Inspector (mẹo 2) thay vì env var SDK — env var và log behavior thay đổi giữa các SDK version.

Nếu muốn log raw trong code của bạn, attach Python logging với level DEBUG trên module mcp:

import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("mcp").setLevel(logging.DEBUG)

Hoặc tự write middleware wrap session.send_request() / session.send_notification() để trace payload trước khi gửi. Verbose output giúp bạn thấy đúng những JSON object như ví dụ step 1-11 ở trên.

Mẹo 2: Dùng MCP Inspector để visualize

Anthropic có tool MCP Inspector — GUI visualize mọi message giữa client-server. Dùng nó khi bạn không muốn đọc log text thô:

npx @modelcontextprotocol/inspector uv run server.py

Nó sẽ mở browser, bạn click quanh và thấy từng message như một chat app.

Mẹo 3: Nhớ 3 message types "bất thường"

Nếu thời gian chỉ học 3 message types để nhớ lâu, chọn 3 cái sau (đây là 3 cái người mới hay miss):

  • notifications/initialized — bước 3 của handshake, thiếu sẽ không gọi tool được.
  • sampling/createMessage — server request, không phải client.
  • notifications/cancelled — có thể đi cả 2 chiều.

Áp dụng ngay

Bài tập 1: Đọc raw messages bằng MCP Inspector (10 phút)

Bước 1: Tạo một server MCP đơn giản với 1 tool add:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(name="demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    return a + b

if __name__ == "__main__":
    mcp.run()

Bước 2: Chạy Inspector:

uv run mcp dev server.py

Bước 3: Trong Inspector UI, click "List Tools" rồi "Call Tool add" với a=3, b=4. Ghi lại:

  • Số lượng message trao đổi (không đếm initialize): ___________
  • id của tools/call request: ___________
  • Có notification nào xuất hiện không? ___________

Bài tập 2 (optional): Viết client thô bằng curl/bash

Chạy server với stdio và gửi JSON thẳng bằng terminal (sẽ học chi tiết ở bài 10.2). Cố gắng hoàn thành một tools/call mà không dùng SDK.

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

Những điều cốt lõi
  • MCP = JSON-RPC 2.0 — mọi message là JSON object với format chuẩn, không ma thuật.
  • Hai nhóm message: Request↔Result và Notification — khác nhau ở field id và việc sender có đợi response không.
  • MCP bidirectional — client gửi request cho server, server cũng gửi request cho client (sampling, roots). Đây là gốc rễ của transport complexity các bài sau.
  • Spec là ground truth — SDK có thể sai, spec thì không. Bookmark schema.ts để tham khảo khi debug.
  • Handshake 3 bước bắt buộc — initialize → InitializeResult → initialized notification. Bỏ sót bước 3 là session hỏng.

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