·

Tiếng Việt: MCP Architecture Hosts Clients Servers

MCP Architecture Hosts Clients Servers

Ở hai bài trước, chúng ta đã hiểu MCP là gì và vì sao nó giúp agent vượt qua giới hạn context window. Bài này đi sâu vào phần mà nhiều người mới học hay nhầm lẫn nhất: ba vai trò (role) trong kiến trúc MCP — host, client, và server — khác nhau ở đâu, giao tiếp với nhau ra sao, và ai chịu trách nhiệm gì về bảo mật. Hiểu rõ ba vai trò này là nền tảng bắt buộc trước khi bạn tự viết một MCP server hay tích hợp MCP vào agent framework của riêng mình.

Ba Vai Trò Trong MCP: Host, Client, và Server

Kiến trúc MCP dùng mô hình client-server quen thuộc, nhưng có thêm một lớp gọi là "host" đứng bao ngoài client. Đây là điểm khác biệt so với các giao thức client-server truyền thống mà bạn cần nắm rõ.

Host (ứng dụng chủ)

Host là ứng dụng người dùng cuối tương tác trực tiếp — ví dụ Claude Desktop, Claude Code, Cursor, hoặc một agent framework tùy biến bạn tự build. Host chịu trách nhiệm:

  • Quản lý vòng đời của một hoặc nhiều MCP client bên trong nó.
  • Điều phối việc gọi LLM (host thường là nơi giữ API key, chọn model, quản lý conversation history).
  • Quyết định chính sách permission — ví dụ hỏi người dùng xác nhận trước khi cho phép một tool "nguy hiểm" (destructive) chạy.
  • Hiển thị UI cho người dùng: danh sách server đã kết nối, log hoạt động, xác nhận consent.

Client (kết nối 1-1 với server)

Client là thành phần nằm bên trong host, chịu trách nhiệm duy trì một kết nối 1-1 với đúng một MCP server. Nếu host kết nối tới 5 MCP server khác nhau (filesystem, GitHub, Slack, Postgres, Jira), host sẽ khởi tạo 5 client instance riêng biệt, mỗi client quản lý một session độc lập. Client xử lý phần giao thức cấp thấp: gửi/nhận JSON-RPC (giao thức gọi hàm từ xa dạng JSON) message, quản lý handshake, theo dõi capability mà server hỗ trợ.

Đây là điểm quan trọng cần phân biệt rõ: client không phải là "app" — nó là một module/thư viện chạy bên trong host, thường được implement bằng SDK chính thức (@modelcontextprotocol/sdk cho TypeScript, mcp package cho Python).

Server (cung cấp năng lực)

Server là process (có thể chạy local hoặc remote) expose các primitive — resources, tools, prompts, sampling — cho client tiêu thụ. Server không biết gì về LLM đang được dùng, không biết gì về các server khác đang kết nối cùng lúc; nó chỉ tập trung vào việc thực hiện đúng chức năng của mình (ví dụ: đọc file, query database, gọi API GitHub) và tuân thủ giao thức MCP để giao tiếp với client.

Sơ đồ quan hệ đơn giản như sau:

Host (Claude Desktop / Claude Code / custom agent)
 ├─ Client 1 ──(stdio)──> MCP Server: filesystem
 ├─ Client 2 ──(stdio)──> MCP Server: github
 └─ Client 3 ──(Streamable HTTP)──> MCP Server: internal-jira (remote)

Một nhầm lẫn phổ biến: nhiều người nghĩ "client" chính là "AI agent". Thực ra agent (tức là vòng lặp reasoning + tool calling) chạy trong host, còn client chỉ là lớp giao tiếp giao thức thuần túy — không có logic AI nào bên trong client.

Mẹo: Khi review code một MCP client implementation, kiểm tra xem nó có đang giữ đúng nguyên tắc 1 client - 1 server không. Một lỗi thiết kế phổ biến ở các bản tự viết là dùng chung một client instance để "multiplex" nhiều server, dẫn đến rò rỉ state (session ID, capability) giữa các server không liên quan — vi phạm đúng kiến trúc mà spec MCP quy định.

Cách Host và Client Negotiate và Khởi Tạo Kết Nối

Trước khi có thể gọi bất kỳ tool hay đọc bất kỳ resource nào, client và server phải trải qua một bước gọi là initialization handshake. Đây là bước bắt buộc theo spec MCP, tương tự handshake TCP/TLS nhưng ở tầng ứng dụng.

Bước 1: Client gửi initialize request

Client gửi một JSON-RPC request với method initialize, kèm theo:

  • protocolVersion — phiên bản spec MCP mà client hỗ trợ (ví dụ "2025-06-18").
  • capabilities — danh sách khả năng client hỗ trợ (ví dụ có hỗ trợ sampling hay không, có hỗ trợ roots — tức đường dẫn thư mục gốc — hay không).
  • clientInfo — tên và version của client (ví dụ {"name": "claude-desktop", "version": "1.4.0"}).

Ví dụ request thực tế (rút gọn):

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": {
      "name": "example-host",
      "version": "1.0.0"
    }
  }
}

Bước 2: Server phản hồi với capability và version của chính nó

Server trả về protocolVersion mà nó sẽ dùng cho session này (thường là version chung cao nhất mà cả hai bên hỗ trợ), cùng danh sách capabilities nó cung cấp (ví dụ có hỗ trợ tools, resources, prompts hay không, và có hỗ trợ tính năng listChanged — tức tự động thông báo khi danh sách tool thay đổi — hay không).

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true }
    },
    "serverInfo": {
      "name": "internal-jira-mcp",
      "version": "0.3.1"
    }
  }
}

Bước 3: Client gửi notifications/initialized

Sau khi nhận phản hồi hợp lệ, client gửi một notification (không cần server trả lời) xác nhận đã sẵn sàng. Từ thời điểm này, session chính thức bắt đầu, và client mới được phép gọi các method khác như tools/list, resources/list, tools/call.

Nếu version không khớp hoàn toàn (ví dụ client hỗ trợ 2025-06-18 nhưng server chỉ hỗ trợ tới 2024-11-05), theo spec, hai bên nên đàm phán về version thấp nhất chung mà cả hai cùng support được — nếu không tìm được điểm chung, kết nối nên bị từ chối một cách tường minh thay vì cố gắng chạy với hành vi không xác định.

Mẹo: Khi debug một MCP server "không thấy tool nào" dù server chạy được, bước đầu tiên nên kiểm tra log của bước handshake — rất nhiều lỗi thực tế đến từ việc server trả sai protocolVersion hoặc thiếu field capabilities.tools trong response của initialize, khiến client (đúng theo spec) coi như server không hỗ trợ tool và không bao giờ gửi tools/list.

Luồng Request/Response: Từ Prompt Của Agent Đến MCP Server và Ngược Lại

Để hình dung rõ luồng hoạt động đầy đủ, hãy theo dõi một ví dụ cụ thể: người dùng gõ prompt "Tìm PR nào đang chờ tôi review trên GitHub" vào Claude Code (host) đã kết nối MCP server GitHub.

Bước 1 — Discovery: Ngay khi khởi tạo session (như mô tả ở phần trên), client đã gọi tools/list để lấy danh sách tool mà server GitHub expose, ví dụ list_pull_requests, get_pr_diff, add_pr_comment. Danh sách này (tên + description + JSON Schema tham số) được host đưa vào context của model dưới dạng tool definition.

Bước 2 — Model quyết định gọi tool: Model (Claude) nhận prompt của người dùng, kết hợp với danh sách tool đang có, và quyết định cần gọi list_pull_requests với tham số {"review_requested": "me", "state": "open"}. Đây là bước function-calling thuần túy ở tầng model.

Bước 3 — Host/Client gửi tools/call: Host nhận tool call từ model, chuyển tiếp qua client tương ứng (client đang giữ kết nối với server GitHub), gửi JSON-RPC request:

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "list_pull_requests",
    "arguments": { "review_requested": "me", "state": "open" }
  }
}

Bước 4 — Server thực thi: Server GitHub MCP nhận request, gọi GitHub REST API (hoặc GraphQL API) thực sự bằng credential đã cấu hình, xử lý kết quả trả về.

Bước 5 — Server trả kết quả: Server phản hồi qua JSON-RPC response, với field content chứa dữ liệu (thường ở dạng text/JSON):

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[{\"number\": 128, \"title\": \"Fix auth timeout\", \"author\": \"vy.tran\"}]"
      }
    ],
    "isError": false
  }
}

Bước 6 — Model tiếp tục suy luận: Client đưa kết quả này vào context như một tool result message, model đọc và sinh câu trả lời tự nhiên cho người dùng: "Bạn có 1 PR đang chờ review: #128 'Fix auth timeout' của vy.tran."

Toàn bộ vòng lặp này có thể lặp lại nhiều lần trong một turn hội thoại — ví dụ model có thể tiếp tục gọi get_pr_diff để xem chi tiết thay đổi trước khi đưa ra nhận xét review.

Mẹo: Khi cần trace lỗi trong một luồng agentic phức tạp gồm nhiều tool call liên tiếp, hãy bật chế độ log JSON-RPC raw (nhiều SDK MCP hỗ trợ biến môi trường như MCP_LOG_LEVEL=debug) để xem chính xác từng request/response — điều này giúp phân biệt rõ lỗi nằm ở tầng model (chọn sai tool/tham số) hay tầng server (xử lý sai, trả lỗi).

Ranh Giới Bảo Mật: Vai Trò Nào Được Phép Làm Gì

Phân tách rõ ba vai trò không chỉ để dễ hiểu về mặt kỹ thuật, mà còn quyết định trực tiếp mô hình bảo mật (threat model) của MCP. Đây là phần mà kinh nghiệm triển khai thực tế cho thấy nhiều đội ngũ bỏ qua, dẫn tới rủi ro nghiêm trọng khi đưa MCP server vào production.

Theo spec MCP, host là nơi bắt buộc phải lấy sự đồng ý (consent) của người dùng trước khi:

  • Kết nối tới một MCP server mới lần đầu.
  • Cho phép server truy cập vào dữ liệu nhạy cảm (ví dụ roots — đường dẫn thư mục — mà host expose cho server).
  • Thực thi một tool được đánh dấu annotation destructive: true hoặc không rõ annotation (ví dụ tool xóa file, tool gửi email cho khách hàng thật).

Một host được implement đúng chuẩn sẽ không bao giờ tự động chạy một tool "nguy hiểm" mà không hỏi lại người dùng, kể cả khi model đã "quyết định" gọi tool đó.

Client chịu trách nhiệm về cô lập (isolation) giữa các server

Client phải đảm bảo mỗi server chỉ nhìn thấy đúng những gì nó cần — ví dụ nếu host cấu hình server filesystem chỉ được truy cập thư mục /home/user/project-a, client phải enforce đúng ranh giới đó (thông qua cơ chế "roots"), không để server tự ý mở rộng phạm vi. Client cũng không được để một server này đọc được dữ liệu/token của một server khác đang chạy song song.

Server chịu trách nhiệm về việc chính nó làm gì với dữ liệu

Server tự chịu trách nhiệm về logic nghiệp vụ bên trong nó — ví dụ một server kết nối database phải tự đảm bảo chỉ chạy query read-only nếu nó tự nhận mình là read-only, tự validate input để chống SQL injection, tự giới hạn rate limit khi gọi API bên thứ ba. MCP protocol không tự động bảo vệ bạn khỏi một server viết cẩu thả — nếu bạn viết một tool run_sql_query nhận nguyên câu SQL từ model mà không sanitize, injection vẫn hoàn toàn có thể xảy ra giống như bất kỳ ứng dụng nào khác.

Bảng tổng hợp trách nhiệm bảo mật theo từng vai trò:

Vai trò Trách nhiệm bảo mật chính Ví dụ rủi ro nếu làm sai
Host Lấy consent người dùng, enforce policy tool nguy hiểm Tự động chạy tool xóa file mà không hỏi
Client Cô lập dữ liệu/token giữa các server, enforce roots Server A đọc được token của server B
Server Validate input, giới hạn phạm vi truy cập, xử lý lỗi an toàn SQL injection qua tool nhận raw query

Một rủi ro đáng chú ý khác trong thực tế: "confused deputy" attack — khi một MCP server remote dùng chung một token OAuth cho nhiều người dùng/tenant khác nhau, kẻ tấn công có thể lợi dụng server để thực hiện hành động vượt quyền dưới danh nghĩa của nạn nhân. Đây là lý do spec 2025-06-18 nhấn mạnh rất nhiều vào việc mỗi user/session cần có token riêng, scope rõ ràng, và server không nên tự ý "cache" quyền hạn giữa các session khác nhau.

Mẹo: Trước khi cài một MCP server bên thứ ba (community server) vào môi trường có dữ liệu nhạy cảm, luôn kiểm tra rõ hai điều: (1) server có annotation đúng cho các tool nguy hiểm (destructive, readOnlyHint) không, và (2) server có scope token hợp lý không (ví dụ chỉ xin quyền repo:read thay vì full repo scope trên GitHub). Đừng bao giờ cấp quyền rộng hơn mức tool thực sự cần, kể cả khi server đó tới từ nguồn có vẻ uy tín.

Nắm vững ba vai trò host-client-server và ranh giới trách nhiệm giữa chúng sẽ giúp bạn tránh được phần lớn lỗi thiết kế phổ biến khi tự viết MCP server hoặc tích hợp MCP vào hệ thống agent của mình. Ở bài tiếp theo, chúng ta sẽ đi sâu vào một khía cạnh kỹ thuật cụ thể hơn nữa: các giao thức transport (stdio, SSE, Streamable HTTP) mà client và server dùng để thực sự trao đổi các message JSON-RPC này qua lại.