Viết MCP client gọi server từ xa của khách: từ mã 401 đến lệnh gọi tool đầu tiên
Khách vừa gửi một URL MCP và bạn chỉ có một buổi chiều để gọi được tool đầu tiên. Phần khó thường nằm ở vài header bị bỏ sót chứ không nằm ở JSON-RPC.
- 1POST initializeAccept khai cả application/json và text/event-stream, có timeout
- 2Nhận 401Đọc header WWW-Authenticate để tìm metadata xác thực
- 3Lấy metadataTừ /.well-known/oauth-protected-resource tìm authorization server cấp token
- 4OAuth có PKCERequest authorize và đổi token đều gửi kèm tham số resource
- 5Initialize lạiBearer token, lưu Mcp-Session-Id, đọc phiên bản từ JSON hoặc SSE
- 6Initialized, gọi tooltools/call mang token, mã phiên và MCP-Protocol-Version
Phần lớn lỗi khi nối vào server MCP của khách nằm ở header, không nằm ở logic agent.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- Streamable HTTP là transport chuẩn cho MCP server từ xa: một endpoint, mỗi thông điệp là một POST mới, client phải chấp nhận cả JSON lẫn SSE.
- Token nằm trong header Authorization ở mọi request, không bao giờ nằm trên query string, và chỉ dùng token do authorization server của chính MCP server đó cấp.
- Sau initialize, mọi request phải gửi lại mã phiên và phiên bản giao thức đã thương lượng. Gặp 404 thì bắt tay lại, và request nào cũng phải có timeout.
Thứ Hai, khách gửi bạn một dòng trong Slack: URL của MCP server nội bộ họ vừa dựng, kèm câu “agent bên anh cứ gọi vào đây”. Bạn thử một POST nhanh và nhận về mã 401. Đội của khách không ngồi cạnh, tài liệu sơ sài, và buổi demo là sáng thứ Năm.
Với một FDE, đây là chuyện rất thường ngày. Server không phải của bạn, hạ tầng xác thực cũng không, nhưng client gọi vào thì bạn phải viết và phải chạy ổn. Phần dưới đây đi từ mã 401 đó đến lệnh gọi tool đầu tiên, từng request một.
Điều cần nhớ trước tiên: ở bước này, phần lớn lỗi nằm ở header HTTP chứ không nằm ở logic agent. Nắm được vài quy tắc của spec, bạn sẽ đọc được lỗi của server khách thay vì đoán mò.
Server từ xa nói chuyện bằng gì?
Spec MCP định nghĩa hai transport chuẩn, và Streamable HTTP là transport dành cho server từ xa. Nó thay thế cơ chế HTTP+SSE của phiên bản 2024-11-05. Nếu bạn gặp code mẫu mở hai kết nối riêng cho SSE và POST, gần như chắc chắn đó là code cũ.
Mô hình của Streamable HTTP rất gọn. Server cung cấp đúng một đường dẫn, gọi là MCP endpoint, nhận cả POST lẫn GET. Mỗi thông điệp JSON-RPC client gửi đi là một POST mới, và header Accept phải liệt kê cả application/json lẫn text/event-stream, vì server có thể trả về JSON thường hoặc một luồng SSE.
Vòng đời cũng có thứ tự cố định. Initialize phải là tương tác đầu tiên; sau khi server trả lời, client gửi notification initialized rồi mới bắt đầu các thao tác bình thường như gọi tool. Thứ tự này không tùy chọn: initialize, rồi initialized, rồi mới đến tool.
Ví dụ: probe server của khách bằng HTTP thô
Trước khi dùng SDK, nên tự gửi request bằng httpx. Bạn sẽ thấy tận mắt server trả gì, và khi SDK báo lỗi mơ hồ, bạn biết nhìn vào đâu. Giả sử endpoint của khách là https://mcp.khach.vn/mcp.
import httpx
URL = "https://mcp.khach.vn/mcp"
H = {"Accept": "application/json, text/event-stream",
"Content-Type": "application/json"}
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "fde-probe", "version": "0.1"}}}
Gửi thử, và đặt timeout ngay từ dòng đầu tiên. Spec khuyến nghị đặt timeout cho mọi request và hủy khi hết hạn, để tránh kết nối treo. Với server nằm sau VPN của khách, điều này càng quan trọng.
r = httpx.post(URL, json=init, headers=H, timeout=10)
print(r.status_code)
print(r.headers.get("www-authenticate"))
Nếu server được bảo vệ, câu trả lời sẽ là 401 kèm header WWW-Authenticate. Spec bắt buộc client đọc được header này và phản hồi đúng cách, vì đây là manh mối dẫn tới metadata xác thực.
Từ 401 đến access token
Metadata của protected resource nằm ở một địa chỉ well-known mặc định: /.well-known/oauth-protected-resource. Từ đó client tìm ra authorization server nào cấp token cho MCP server này.
meta_url = "https://mcp.khach.vn/.well-known/oauth-protected-resource"
meta = httpx.get(meta_url, timeout=10).json()
as_url = meta["authorization_servers"][0]
Bước kế tiếp cần hai địa chỉ của authorization server: nơi nhận request authorize và nơi cấp token. Cách tra ra chúng tùy vào hạ tầng của khách, nên trong ví dụ dưới đây chúng là hai biến giả định AUTHORIZE và TOKEN_ENDPOINT, cùng CLIENT_ID và REDIRECT.
Sau đó là luồng OAuth, và spec đặt hai yêu cầu cứng: client phải dùng PKCE, và phải gửi tham số resource để token bị ràng buộc với đúng MCP server đích. Phần chuẩn bị trông như sau.
import base64, hashlib, secrets
from urllib.parse import urlencode
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
auth_params = {"response_type": "code", "client_id": CLIENT_ID,
"redirect_uri": REDIRECT, "code_challenge": challenge,
"code_challenge_method": "S256", "resource": URL}
login_url = f"{AUTHORIZE}?{urlencode(auth_params)}"
Người dùng mở login_url trong trình duyệt và đăng nhập, rồi authorization server trả code về redirect. Client đổi code lấy token, gửi kèm verifier và lặp lại đúng giá trị resource.
tok = httpx.post(TOKEN_ENDPOINT, timeout=10, data={
"grant_type": "authorization_code", "code": code,
"redirect_uri": REDIRECT, "client_id": CLIENT_ID,
"code_verifier": verifier, "resource": URL}).json()
Một lời khuyên thực tế: với server nội bộ của khách, nhiều khả năng bạn sẽ phải hỏi đội của họ về CLIENT_ID, REDIRECT và hai endpoint kia. Hãy đưa chúng vào danh sách câu hỏi của cuộc gọi đầu tiên, trước khi viết dòng code nào.
Bắt tay lần hai, lần này đầy đủ
Có token rồi, gửi lại initialize. Token nằm trong header Authorization, và spec yêu cầu header này có mặt ở mọi request, kể cả khi các request cùng thuộc một phiên logic.
H["Authorization"] = f"Bearer {tok['access_token']}"
r = httpx.post(URL, json=init, headers=H, timeout=10)
sid = r.headers.get("mcp-session-id")
if sid:
H["Mcp-Session-Id"] = sid
Nếu server cấp Mcp-Session-Id lúc khởi tạo, client phải gửi lại nó ở mọi request sau đó. Client cũng phải gửi header MCP-Protocol-Version mang phiên bản đã thương lượng. Đừng chép cứng con số bạn đã đề xuất, hãy đọc phiên bản server trả về trong kết quả initialize.
Vì Accept cho phép cả SSE, câu trả lời của initialize có thể là một luồng sự kiện thay vì JSON. Đoạn dưới xử lý cả hai nhánh: với SSE, lấy dòng data rồi mới parse.
import json
ctype = r.headers.get("content-type", "")
if ctype.startswith("text/event-stream"):
line = next(l for l in r.text.splitlines() if l.startswith("data:"))
body = json.loads(line[5:])
else:
body = r.json()
H["MCP-Protocol-Version"] = body["result"]["protocolVersion"]
Rồi mới gửi notification initialized, lúc này đã mang đủ header.
note = {"jsonrpc": "2.0", "method": "notifications/initialized"}
httpx.post(URL, json=note, headers=H, timeout=10)
Nếu gọi thẳng .json() mà không kiểm tra Content-Type, bạn có thể mất nửa giờ với một lỗi parse vô nghĩa.
Lệnh gọi tool đầu tiên
Giờ H đã chứa đủ Accept, Authorization, Mcp-Session-Id và MCP-Protocol-Version. Chọn một tool vô hại để gọi, kiểu tra cứu chỉ đọc. Tên tool và tham số dưới đây chỉ là ví dụ giả định, hãy thay bằng tool thật mà server của khách công bố.
call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "tra_cuu_don_hang",
"arguments": {"ma_don": "DH001"}}}
r = httpx.post(URL, json=call, headers=H, timeout=10)
print(r.status_code, r.headers.get("content-type"))
Nhận được 200 kèm kết quả là bạn đã đi hết một vòng: từ 401, qua OAuth, đến một lệnh gọi tool thật. Hãy lưu lại log của lần chạy này, đó là tài liệu tham chiếu tốt nhất bạn có trong những ngày tới.
Hiểu header rồi, giao phần nặng cho SDK
Probe bằng HTTP thô là để hiểu, không phải để chạy production. README của Python SDK chính thức ghi rằng truyền một URL vào Client tức là chọn Streamable HTTP, và tool được gọi bằng call_tool. Tutorial xây client chính thức yêu cầu SDK từ 2.0.0 trở lên, nên hãy kiểm tra phiên bản đã cài trước khi chép bất kỳ đoạn mẫu nào trên mạng.
Thứ tự làm việc an toàn nhất là probe bằng HTTP thô cho tới khi tools/call thành công, ghi lại toàn bộ header của từng bước, rồi mới chuyển sang SDK và gắn luồng OAuth theo hướng dẫn của đúng phiên bản. Sau đó dùng call_tool gọi lại chính tool chỉ đọc đã thử, và cuối cùng mới nối vào agent.
Khi SDK báo lỗi, quay về bản ghi header từ bước probe. Phần lớn trường hợp, bạn sẽ thấy ngay header nào đang thiếu.
Những lỗi khiến buổi demo đổ vỡ
Lỗi phổ biến nhất là đặt token trên URL, kiểu thêm ?access_token= cho nhanh khi thử. Spec cấm điều này rõ ràng, và URL thường bị ghi vào log của proxy lẫn gateway phía khách. Token chỉ được nằm trong header Authorization.
Lỗi tiếp theo tinh vi hơn: dùng nhầm token. Khách có sẵn API key cho hệ thống nội bộ khác, và ai đó đề nghị “dùng tạm cái này”. Spec cấm client gửi cho MCP server bất kỳ token nào không do authorization server của chính server đó cấp; tham số resource tồn tại cũng vì lý do tương tự.
Một lỗi nữa là coi 404 là lỗi chết. Khi server trả 404 cho một request mang mã phiên, client phải khởi tạo lại từ đầu. Code của bạn cần một nhánh bắt tay lại có giới hạn số lần, thay vì ném exception lên agent.
Còn ba lỗi nhỏ nhưng tốn giờ: Accept chỉ khai một content type, quên gửi lại MCP-Protocol-Version sau initialize, và request không có timeout khiến agent treo khi VPN của khách chập chờn. Cộng thêm việc chép code HTTP+SSE cũ hoặc snippet SDK dưới 2.0, bạn có đủ danh sách để soát trước mỗi buổi demo.
Đưa kỹ năng này vào CV thế nào?
Khi đọc JD của các vị trí FDE, hãy để ý những cụm như “integrate with customer systems”, “OAuth”, “MCP” hay “agent tooling”. Đó là dấu hiệu công việc sẽ có đúng tình huống ở đầu bài.
Trong CV, một dòng cụ thể có sức nặng hơn một danh sách công nghệ. Chẳng hạn: “Viết MCP client qua Streamable HTTP với OAuth (PKCE, resource), xử lý phiên hết hạn và timeout khi gọi server sau VPN của khách.” Người phỏng vấn sẽ hỏi tiếp về 401 và 404, và lúc đó bạn đã có câu trả lời từ chính script probe của mình.
Lần tới khách gửi một URL kèm câu “cứ gọi vào đây”, thứ đầu tiên bạn mở sẽ không phải SDK mà là một script ngắn in ra header của từng bước.