Xây Dựng MCP Server và Client Thực Chiến

Một MCP server tối thiểu dài khoảng 40 dòng. Nhưng đặc tả 2026-07-28 đã bỏ handshake và session, nên phần lớn tutorial bạn tìm được trên mạng đang dạy một giao thức khác. Bài này đi từ server 40 dòng đến client biết xử lý input_required, rồi đến những thứ quyết định việc nó có sống được trong production: auth, egress, secret và test.

Cập nhật 2026

  • Đặc tả hiện hành 2026-07-28: stateless, không initialize, không Mcp-Session-Id. Mỗi request mang protocolVersion và clientCapabilities trong _meta.
  • Chỉ còn hai transport: stdio và Streamable HTTP. HTTP+SSE đã deprecated, resume SSE đã bị bỏ.
  • SDK: Python mcp 2.3.0, TypeScript 1.32.2, .NET 2.4.0. Ba major khác nhau; snippet không dùng chéo được.
  • Registry chính thức vẫn preview, dữ liệu có thể reset, và chỉ chứa metadata — code nằm ở package registry.

Ai nói chuyện với ai

MCP có ba vai: host (ứng dụng AI), client (thành phần trong host giữ kết nối tới một server), và server (cung cấp tools, resources, prompts). Một host có thể có nhiều client, mỗi client nói với một server.

Vì giao thức stateless, server không nhớ client giữa hai request. Nếu một tool cần trạng thái xuyên call — giỏ hàng, phiên tra cứu, cursor phân trang — server tự cấp một handle và client gửi lại handle như một tham số tool bình thường. Đây là quyết định thiết kế, không phải chi tiết: nó cho phép server chạy sau load balancer không sticky và trong serverless.

Server tối thiểu, không dùng SDK

Để thấy rõ giao thức, viết server bằng Python thuần trên stdio. Mỗi dòng là một JSON-RPC message; không có bước khởi tạo.

# File: server.py — chạy: python server.py
import json, sys

ORDERS = {"A-1001": {"status": "shipped", "carrier": "GHN"},
          "A-1002": {"status": "pending", "carrier": None}}

def discover(_params):
    # Dạng rút gọn; field khác trong spec (instructions, extensions...) tuỳ nhu cầu
    return {"protocolVersion": "2026-07-28",
            "serverInfo": {"name": "orders", "version": "1.4.2"},
            "capabilities": {"tools": {}, "resources": {}}}

def list_tools(_params):
    return {"tools": [{
        "name": "get_order",
        "description": "Tra trạng thái đơn hàng theo mã. Dùng khi đã có order_id.",
        "inputSchema": {"type": "object",
                        "properties": {"order_id": {"type": "string"}},
                        "required": ["order_id"]}}]}

def call_tool(params):
    args = params.get("arguments") or {}
    order = ORDERS.get(args.get("order_id"))
    if order is None:
        return {"content": [{"type": "text",
                             "text": "Không tìm thấy đơn. Hãy kiểm tra lại order_id."}],
                "isError": True}
    return {"content": [{"type": "text", "text": json.dumps(order)}]}

HANDLERS = {"server/discover": discover, "tools/list": list_tools,
            "tools/call": call_tool}

def dispatch(req):
    meta = (req.get("params") or {}).get("_meta") or {}
    if meta.get("protocolVersion") != "2026-07-28":
        return {"jsonrpc": "2.0", "id": req.get("id"),
                "error": {"code": -32600, "message": "unsupported protocolVersion"}}
    fn = HANDLERS.get(req.get("method"))
    if fn is None:
        return {"jsonrpc": "2.0", "id": req.get("id"),
                "error": {"code": -32601, "message": "method not found"}}
    return {"jsonrpc": "2.0", "id": req.get("id"), "result": fn(req["params"])}

for line in sys.stdin:
    if line.strip():
        print(json.dumps(dispatch(json.loads(line))), flush=True)

Điểm đáng chú ý: kiểm tra protocolVersion nằm trong từng request; không có biến global nào giữ client. Nếu bạn thêm state, nó phải là handle do tool trả về, không phải biến trong process — trừ khi bạn chấp nhận mất state khi scale ngang.

Với SDK Python 2.3.0, bạn không phải tự viết vòng lặp này; SDK lo JSON-RPC, transport và schema. Nhưng API của SDK 2.x khác 1.x, và tên decorator/hàm cụ thể không nằm trong bài này — hãy đọc README của đúng bản 2.3.0 thay vì copy snippet 1.x. Đây là lỗi phổ biến nhất khi dựng server từ blog cũ.

Client tối thiểu, có xử lý input_required

Client phải làm ba việc: gắn _meta vào mọi request, gọi server/discover một lần để biết server có gì, và xử lý vòng lặp nhiều lượt khi server cần thêm thông tin.

# File: client.py — gọi server qua Streamable HTTP
import json, urllib.request

ENDPOINT = "https://mcp.example.com/mcp"
TOKEN = "..."  # lấy từ OAuth flow, không hard-code

def rpc(method, params, rid):
    body = {"jsonrpc": "2.0", "id": rid, "method": method,
            "params": {**params,
                       "_meta": {"protocolVersion": "2026-07-28",
                                 "clientCapabilities": {"elicitation": {}}}}}
    req = urllib.request.Request(
        ENDPOINT, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {TOKEN}"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())

def ask_user(input_requests):
    # Dạng entry {type, message, schema} là shape phổ biến nhưng KHÔNG được
    # đặc tả trong fact base — đối chiếu spec 2026-07-28 trước khi chốt.
    answers = {}
    for item in input_requests:
        answers[item["message"]] = input(
            f"{item['message']} (schema: {item.get('schema')}): ")
    return answers

def call_tool(name, arguments):
    rid = 1
    params = {"name": name, "arguments": arguments}
    while True:
        res = rpc("tools/call", params, rid)
        result = res["result"]
        if result.get("resultType") != "input_required":
            return result
        params["inputResponses"] = ask_user(result["inputRequests"])
        rid += 1

Vòng lặp while là phần nhiều client mẫu bỏ qua. Nếu server trả resultType: "input_required" mà client coi là kết quả cuối, người dùng nhận về một object rỗng thay vì câu trả lời. Điều này xảy ra thường xuyên với tool cần chọn chi nhánh, xác nhận trước khi ghi, hoặc bổ sung tham số thiếu.

Ba primitive, và một primitive phía client

  • Tools — hàm có inputSchema, model được gọi. Mô tả tool là một phần của prompt, nên nó quyết định model chọn đúng hay sai. Viết mô tả như tài liệu API: làm gì, khi nào dùng, khi nào không, tham số nào bắt buộc.
  • Resources — dữ liệu đọc được theo URI: file, schema, tài liệu. Khác tool ở chỗ không có side effect và thường được host nạp vào context có kiểm soát.
  • Prompts — template do server cung cấp. Hữu ích khi server muốn chuẩn hoá cách dùng năng lực của mình thay vì để mỗi host tự viết prompt.
  • Elicitation — phía client: server yêu cầu host hỏi người dùng. Đây là lý do client phải có UI, không chỉ có hàm gọi.

Một lưu ý về chi phí: schema của mọi tool lặp lại trong mỗi request vì giao thức stateless. Catalog 40 tool vừa tốn input token vừa làm model khó chọn. Tách server theo miền nghiệp vụ, và giữ danh sách tool ngắn.

Auth cho server từ xa

stdio dùng credential từ biến môi trường, do host truyền vào process con. Với Streamable HTTP, chuẩn là OAuth 2.1:

  • PKCE bắt buộc cho public client.
  • RFC 9728 — protected-resource metadata để client biết authorization server nào.
  • RFC 8707 — resource indicators, để token lấy cho server A không bị replay sang server B.
  • RFC 9207 — tham số iss chống mix-up giữa các authorization server.
  • Client ID Metadata Documents là cách đăng ký client được khuyến nghị; Dynamic Client Registration đã deprecated.

Phần dễ làm sai: coi scope là chi tiết cấu hình. Token phải scope theo tenant và theo tool; một token đọc được mọi tenant là LLM06 excessive agency chờ xảy ra. Và token không được nằm trong tool arguments hay trong message — nó nằm ở header, do client giữ.

Extension: Tasks, Skills, Apps

Ba extension đang được dùng:

  • Tasks (tasks/get, tasks/update) — cho công việc dài, thay vì giữ một request mở hàng phút. Client poll trạng thái hoặc cập nhật task.
  • Skills over MCP — đóng gói quy trình dưới dạng skill, nạp theo nhu cầu thay vì nhét hết vào system prompt.
  • MCP Apps — server cung cấp UI tương tác, không chỉ text.

Registry preview có REST API và server.json, xác minh namespace qua GitHub hoặc DNS. Nhưng registry chỉ là metadata: một entry không chứng nhận code an toàn, và dữ liệu có thể reset. Đối xử với MCP server như mọi dependency khác — kiểm tra publisher, pin version, đọc code nếu có quyền truy cập hệ thống thật.

Bảo mật khi cắm server vào agent có quyền

  • Tool description là dữ liệu không tin cậy — nó đến từ server, có thể chứa chỉ dẫn độc hại. Coi nó như input, không như system prompt.
  • Tool output là dữ liệu không tin cậy — indirect prompt injection qua kết quả tool là vector chính (LLM01). Không render output trực tiếp, không thực thi nó.
  • Egress allowlist — server chỉ được gọi đích đã biết; chặn cloud metadata; tách network build và run.
  • Secret ngoài context — inject token ngắn hạn ở biên, scope theo task; rotate; redact log và trace.
  • Idempotency cho tool có side effect — giao thức không giải quyết retry; tool phải nhận key.
  • Phê duyệt cho hành động không đảo — chuyển tiền, ghi production, export. Ghi lại người duyệt và tham số.

MCP là giao thức, không phải sandbox. Nếu server chạy code không tin cậy, nó cần isolation thật — container thường không phải ranh giới bảo mật cho code đối kháng.

Test một MCP server

Bộ test tối thiểu, chạy được trong CI:

  1. Discover — trả đúng protocolVersion, serverInfo, danh sách capability. Test cả request thiếu _meta.
  2. Tools/list — schema hợp lệ, mô tả không rỗng, không có tool trùng tên.
  3. Tools/call happy path — tham số hợp lệ, kiểm tra cả nội dung và kiểu trả về.
  4. Lỗi — tool trả isError với thông điệp hữu ích, không trả stack trace.
  5. Input_required — client giả lập trả lời và server hoàn tất task; test cả trường hợp người dùng huỷ.
  6. Statelessness — hai request song song từ hai client không thấy state của nhau.
  7. Auth — token sai scope bị 403; token của tenant A không đọc được dữ liệu tenant B.
  8. Injection — tool trả về chuỗi chứa chỉ dẫn độc hại; host không thực thi và không đưa vào system prompt.
  9. Tải — catalog lớn, response chậm, client timeout; kiểm tra server không giữ kết nối chết.

Đừng chỉ test happy path. Phần lớn lỗi production nằm ở đường input_required, đường lỗi và đường auth — ba đường mà tutorial thường bỏ qua.

Cạm bẫy

  • Copy snippet 1.x — SDK 2.3.0 khác API; kiểm tra version trước khi copy.
  • Dựng HTTP+SSE — đã deprecated, resume bị bỏ. Dùng Streamable HTTP.
  • Giữ session trong RAM server — chạy được một instance, chết khi scale.
  • Không kiểm tra protocolVersion — client mới gặp server cũ sẽ nhận lỗi khó hiểu thay vì thông điệp rõ ràng.
  • Tool quá nhiều trên một server — schema lặp trong mọi request, chi phí input tăng mà không ai để ý.
  • Coi registry là chứng nhận an toàn — registry chỉ là metadata, publisher mới là thứ cần kiểm tra.

Checklist trước khi phát hành

  1. Pin đặc tả 2026-07-28 trong README và trong test; ghi rõ SDK version.
  2. Chỉ dùng stdio hoặc Streamable HTTP; gỡ mọi tham chiếu HTTP+SSE.
  3. Mọi request có _meta; server không giả định session.
  4. Handle cho state xuyên call là tham số tool, có TTL và kiểm tra quyền sở hữu.
  5. Remote server dùng OAuth 2.1 + PKCE + RFC 8707; không dùng DCR làm mặc định.
  6. Tool schema hẹp, mô tả rõ, lỗi có cấu trúc, side effect có idempotency key.
  7. Egress allowlist, secret ngoài context, token scope theo tenant/tool.
  8. Test discover, lỗi, input_required, auth và injection, không chỉ happy path.

Hướng tới 2027

Stateless là thay đổi đúng hướng: nó làm MCP chạy được trong serverless, sau load balancer và trong gateway đa tenant. Phần còn thiếu là registry ổn định và sự đồng nhất giữa các SDK major. Khi ba SDK cùng phiên bản hóa theo đặc tả, việc build server sẽ giống viết một REST service bình thường — và phần khó sẽ chỉ còn là quyền, egress và test.

Đọc tiếp: MCP 2026-07-28 và quan hệ với A2A, thiết kế tool, đầu vào/đầu ra và xử lý lỗi, prompt injection và sandboxing, và A2A và multi-agent.