Quan điểm thẳng: tool MCP viết 5 dòng đủ để học, không đủ để ship. Tool get_weather trong bài 2 không validation, không error code, không annotation, chạy được trên Claude Desktop của tôi nhưng sẽ chết ngay khi gặp LLM gửi city: 42 (number thay vì string). Cuối tuần trước tôi review một MCP server team viết, có một tool tên là update (đúng vậy, chỉ “update”), không description, schema chấp nhận arbitrary object. LLM gọi tool đó 7 lần trong một conversation, mỗi lần làm việc khác nhau, một trong số đó xoá nhầm record. Đó là cost của shortcut.
Bài này khác. Tôi viết về thiết kế Tool MCP đúng nghĩa production: tên ra sao để LLM chọn đúng, schema kín kẽ cỡ nào, idempotent thực sự nghĩa là gì khi server lỡ tay trả 500, dry-run đặt ở đâu cho hợp lý, và bốn annotation hint (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) thực sự để làm gì. Spec tham chiếu là MCP Server Tools 2025-06-18.
Tool design bắt đầu từ tên
LLM chọn tool dựa trên ba thứ theo thứ tự ưu tiên giảm dần: name, description, inputSchema. Tên rõ thì model gần như không cần đọc description. Tên mập mờ thì model rơi vào “đoán bằng schema” và tỉ lệ chọn sai tăng đột biến.
Quy tắc tôi áp dụng cho name ngắn gọn thế này. Luôn dùng động từ hiện tại đơn ở đầu (create_issue, read_file, send_message, list_pull_requests), tránh danh từ đơn lẻ kiểu issue hay file vì LLM không biết là đọc hay tạo. Snake_case cho Python SDK, camelCase nếu server TS muốn nhất quán với phần còn lại; spec không ép, nhất quán trong cùng server quan trọng hơn. Tránh viết tắt domain riêng: create_pr ổn vì PR phổ biến; create_mr (merge request kiểu GitLab) thì viết thẳng create_merge_request. Một tool một intent: update_or_create_user nghe tiện cho code nhưng LLM phân vân giữa “update” và “create”, tách hai tool hoặc đặt upsert_user để intent rõ.
title là field optional, dành cho UI hiển thị cho user, không phải cho LLM. Spec ghi rõ: client coi title là để show cho người, name là để model chọn. Đặt title kiểu “Create GitHub Issue” trong khi name là create_issue.
description là chỗ tôi viết “khi nào nên dùng tool này, khi nào không”. Tôi luôn nhồi 1-2 câu negative (“không dùng tool này nếu…”) vì LLM rất dễ over-call một tool có description quá rộng. Cá nhân tôi thấy đây là chỗ tốn nhất nhưng trả về nhiều nhất: description tốt giảm hẳn tỉ lệ tool bị gọi sai context.
Input schema kín kẽ
Spec yêu cầu inputSchema là một JSON Schema, kiểu object. Mọi thứ bạn không khai báo là mọi thứ LLM được phép ngẫu hứng.
Ví dụ schema cho tool create_issue:
{
"type": "object",
"properties": {
"repo": {
"type": "string",
"pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
"description": "GitHub repo dạng owner/name, ví dụ llawliet11/blog-heniart"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"body": {
"type": "string",
"maxLength": 65536
},
"labels": {
"type": "array",
"items": { "type": "string" },
"maxItems": 10,
"uniqueItems": true
}
},
"required": ["repo", "title"],
"additionalProperties": false
}
Bốn thứ tôi luôn để ý trong schema. Đầu tiên là additionalProperties: false. JSON Schema mặc định cho phép field lạ, LLM nhiều khi sinh field rác (description thay vì body, hoặc nhồi thêm priority không có trong schema), khóa luôn để server raise lỗi rõ. Tiếp theo, pattern cho field có shape cố định (repo, email, slug); LLM hay sinh string đẹp ngữ pháp nhưng sai format, pattern bắt sớm. Đừng quên minLength, maxLength, minimum, maximum; chỉ khai type: "string" rồi để app crash khi nhận "" là lỗi tôi đã debug ba lần ở ba server khác nhau. Cuối cùng, enum cho field hữu hạn: status: { type: "string", enum: ["open", "closed"] } mạnh hơn rất nhiều so với status: { type: "string" }.
Cùng schema viết bằng Zod (TypeScript SDK) gọn hơn nhiều:
import { z } from "zod";
const CreateIssueInput = z.object({
repo: z
.string()
.regex(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, "Phải có dạng owner/name"),
title: z.string().min(1).max(256),
body: z.string().max(65536).optional(),
labels: z.array(z.string()).max(10).optional(),
}).strict();
.strict() của Zod tương đương additionalProperties: false. Khi đăng ký tool, TypeScript SDK tự convert Zod schema thành JSON Schema để gửi sang client.
Python SDK (FastMCP) đi đường khác: tận dụng type annotation và Pydantic. Cùng tool đó viết Python:
from typing import Annotated
from pydantic import Field
from fastmcp import FastMCP
mcp = FastMCP(name="GitHubServer")
@mcp.tool
def create_issue(
repo: Annotated[
str,
Field(pattern=r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
description="GitHub repo dạng owner/name"),
],
title: Annotated[str, Field(min_length=1, max_length=256)],
body: Annotated[str, Field(max_length=65536)] = "",
labels: Annotated[list[str], Field(max_length=10)] | None = None,
) -> dict:
"""Tạo issue mới trên GitHub. Không dùng tool này để comment vào issue có sẵn (xem add_issue_comment)."""
return {"number": 42, "url": "https://github.com/..."}
FastMCP tự sinh JSON Schema từ Pydantic, bao gồm cả constraint, description, và mark field optional dựa vào default value. Docstring của function trở thành description của tool.
Output structure: structured content + outputSchema
Tool result trong MCP có hai dạng (spec mục Tool Result):
- Unstructured: trả về
contentlà array các content item kiểu{type: "text", text: "..."},{type: "image", ...},{type: "resource_link", ...}. LLM đọc text. - Structured: trả về
structuredContentlà một JSON object. Client validate object đó vớioutputSchema(nếu server có khai báo).
Spec khuyên: nếu trả structured, vẫn nên trả thêm bản serialized JSON trong một text content block để giữ tương thích ngược với client cũ chưa hỗ trợ structuredContent.
Khai báo outputSchema mang lại bốn lợi ích, theo spec:
- Cho phép client validate strict response.
- Cung cấp type info để code phía client lấy data dễ.
- Hướng LLM parse và sử dụng đúng cấu trúc.
- Tài liệu hóa tự động (dev tool có thể sinh docs từ schema).
Ví dụ tool weather với outputSchema:
server.registerTool(
"get_weather",
{
title: "Weather Information Provider",
description: "Lấy thời tiết hiện tại tại một thành phố hoặc zip code",
inputSchema: { location: z.string() },
outputSchema: {
temperature: z.number().describe("Nhiệt độ °C"),
conditions: z.string(),
humidity: z.number().min(0).max(100),
},
},
async ({ location }) => {
const data = await fetchWeather(location);
const payload = {
temperature: data.temp,
conditions: data.cond,
humidity: data.hum,
};
return {
structuredContent: payload,
content: [{ type: "text", text: JSON.stringify(payload) }],
};
},
);
Tôi gần như luôn dùng outputSchema cho tool nào trả về data có shape rõ. Tool nào output thuần prose (kiểu tóm tắt văn bản) thì không cần.
Error vs Exception: hai cơ chế khác nhau
Đây là chỗ rất nhiều người mới mắc lỗi. MCP có hai cơ chế báo lỗi, dùng cho hai mục đích khác nhau (spec mục Error Handling):
1. Protocol Error: JSON-RPC standard error, dùng khi client phá vỡ contract:
- Tool name không tồn tại (
-32602 Invalid params). - Arguments không khớp schema.
- Internal server crash (
-32603 Internal error).
Server trả về error field thay vì result. Client biết là lỗi protocol, không pass cho LLM xử lý.
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32602,
"message": "Unknown tool: invalid_tool_name"
}
}
2. Tool Execution Error: business logic fail, đặt isError: true trong result:
- API rate limit, network timeout.
- Repo không tồn tại, user không có quyền.
- Input pass schema nhưng nghiệp vụ refuse (ví dụ “không tạo được issue trên repo archived”).
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{ "type": "text", "text": "Failed to fetch weather: API rate limit exceeded" }
],
"isError": true
}
}
Khác biệt quan trọng: tool execution error vẫn là một result hợp lệ, content được pass cho LLM để model retry, switch tool khác, hoặc giải thích cho user. Protocol error thì client thường drop ngay, không cho LLM thấy.
Rule của tôi: input không hợp lệ về schema thì protocol error. Mọi thứ liên quan đến nghiệp vụ và bên ngoài (network, DB, API) thì tool execution error với message human-readable.
Bằng FastMCP, dùng ToolError cho execution error:
from fastmcp.exceptions import ToolError
@mcp.tool
def divide(a: float, b: float) -> float:
"""Chia a cho b."""
if b == 0:
raise ToolError("Chia cho 0 không hợp lệ")
return a / b
ToolError message đảm bảo đến tay client. Exception khác (ValueError, RuntimeError) bị server log internally và trả về error chung chung “Internal error”. Đó là lý do nên dùng mask_error_details=True cho server production: không leak stack trace ra client.
Idempotency: hiểu đúng mới mark đúng
Idempotent nghĩa là gọi f(x) hai lần ra cùng kết quả như gọi một lần. Concept toán học, nhưng trong MCP nó mapping vào câu hỏi “client có nên retry tool này khi timeout không?”.
Một số ví dụ thường bị nhầm:
| Tool | Idempotent? | Lý do |
|---|---|---|
set_user_name(id, "Nia") | Yes | Gọi 10 lần vẫn là tên “Nia” |
add_to_cart(item) | No | Gọi 2 lần ra 2 item trong cart |
toggle_setting(key) | No | Gọi 2 lần bằng không gọi (flip back) |
delete_file(path) | Yes | Lần 2 file đã không còn (kết quả vẫn “file không tồn tại”) |
create_issue(title, body) | No | Lần 2 ra issue trùng |
upsert_user(id, data) | Yes | Khái niệm upsert ngầm hứa idempotent |
Đặc biệt: idempotent không đồng nghĩa với safe. Một tool format ổ cứng vẫn là idempotent (format lần 2 vẫn ra ổ cứng trống), nhưng cực kỳ destructive. Đây là lỗi rất phổ biến mà bài blog Tool Annotations as Risk Vocabulary đã nhấn mạnh: đừng conflate idempotency với safety.
Khi thiết kế tool ghi data, có vài cách làm idempotent:
- Client cung cấp idempotency key: ví dụ tool
charge_payment(amount, idempotency_key). Server check key, nếu đã thấy trước thì return result cũ. - Natural key trên content: tool
create_issuecó thể check “issue cùng title trong 5 phút trước” và return issue cũ thay vì tạo trùng. Cẩn thận, vì LLM có thể dùng cùng title cho 2 ý khác nhau. - Mặc định non-idempotent, document rõ: cách dễ và an toàn nhất. Khai báo
idempotentHint: false, client biết không nên auto-retry.
Side effects và dry-run pattern
Tool có side effect (gửi email, charge tiền, deploy code) cần thêm safeguard ngoài schema. Pattern tôi hay dùng là dry-run flag:
const SendEmailInput = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string().min(1).max(100000),
dryRun: z
.boolean()
.default(true)
.describe(
"Nếu true (default), chỉ validate input và return preview, không thực sự gửi email. Set false để gửi thật.",
),
});
Vài ghi chú:
- Default phải là true (an toàn). LLM thường không truyền field optional, nên default
truenghĩa là “không gửi” trừ khi user/agent chủ động xác nhận. - Response của dry-run mode phải có shape giống response thật. Lý do: LLM nhìn vào response để hiểu sẽ có gì xảy ra. Nếu dry-run trả ngắn gọn còn response thật trả dài, LLM không biết chuẩn bị gì.
- Khi
dryRun: false, server vẫn nên log đầy đủ input + output. Audit trail là phần không thương lượng cho tool destructive.
Pattern tương đương: trả về một “plan” rồi yêu cầu client gọi execute_plan(plan_id) ở step 2. Phức tạp hơn dry-run flag, nhưng tách rõ “review” và “commit”. Tôi dùng pattern này cho tool migration database, tool deploy infrastructure.
Tool annotations: bốn hint, không phải bốn rào chắn
MCP spec định nghĩa năm field trong annotations: title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Chúng là hint, không phải contract. Spec ghi rõ: client MUST treat annotations từ untrusted server là không đáng tin cậy.
Ý nghĩa từng hint, theo bài blog chính thức của MCP team:
readOnlyHint: true: tool chỉ đọc, không modify state ở đâu hết. Lưu ý: nếu tool log query vào analytics DB, không còn read-only.destructiveHint: true: tool có thể gây thay đổi không undo được. Không chỉ là “delete”. Overwrite file, revoke token, close issue đều destructive. MarkdestructiveHint: falsecho operation thuần thêm dữ liệu.idempotentHint: true: gọi nhiều lần cùng arguments ra cùng kết quả. Xem bảng ở mục trên để mark đúng.openWorldHint: true: tool tương tác với “thế giới mở” (internet, third-party API có thể down/đổi behavior). Tool đọc file local: closed world. Tool gọi GitHub API: open world.
Default khi không khai báo annotation là paranoid: client coi tool là destructive, non-idempotent, open-world. User sẽ bị prompt xác nhận trên mỗi call. Nếu bạn build server mà skip phần annotation, UX của client sẽ chậm và phiền.
Ví dụ đầy đủ với annotation, dùng FastMCP:
from typing import Annotated
from pydantic import Field
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = FastMCP(name="WeatherServer", mask_error_details=True)
@mcp.tool(
annotations=ToolAnnotations(
title="Get Weather",
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=True,
)
)
def get_weather(
location: Annotated[
str,
Field(min_length=1, max_length=100,
description="City name hoặc zip code"),
],
) -> dict:
"""Trả về thời tiết hiện tại cho location.
Tool chỉ đọc dữ liệu từ weather API public, không modify gì.
Không dùng cho dự báo nhiều ngày (xem get_forecast).
"""
try:
data = fetch_weather_api(location)
except RateLimitError:
raise ToolError(f"Rate limit weather API, thử lại sau 60s")
return {
"temperature": data["temp_c"],
"conditions": data["cond"],
"humidity": data["hum"],
}
Tool này khai báo: read-only, không destructive, idempotent (gọi 2 lần cùng city vẫn ra cùng cấu trúc data, dù value có thay đổi theo thời gian thực), open-world (gọi external API).
Khía cạnh “idempotent dù value thay đổi” hay gây tranh cãi. Quan điểm của tôi: idempotent ở đây nghĩa là client retry vẫn an toàn (không gây side effect trùng), không phải “value identical”. Spec không define cứng, nên về thực hành tôi nghiêng về interpretation “retry-safe” vì đó là mục đích chính của hint.
Checklist tự kiểm trước khi ship tool
Trước khi commit một tool MCP mới, tôi chạy qua checklist sau:
- Name: động từ, một intent, không viết tắt mập mờ.
- Description: nói rõ khi nào dùng + khi nào không. Có example trong description nếu schema phức tạp.
- inputSchema:
additionalProperties: false, pattern cho field có shape cố định, enum cho field hữu hạn, min/max cho number/string/array. - outputSchema: khai báo nếu output có shape rõ. Trả cả
structuredContentlẫn serialized JSON trong text block. - Error: schema fail thì protocol error. Business fail thì
isError: truevới message human-readable. - Idempotency: đã verify thật sự retry-safe chưa, hay chỉ “hy vọng” idempotent. Có idempotency key nếu cần.
- Side effect: tool có ghi state không. Nếu có và không dễ undo, mark
destructiveHint: true. Cân nhắc dry-run defaulttrue. - Annotations: cả bốn hint đã set tường minh, không skip (default paranoid sẽ phiền user).
- Logging: input + output (sanitized) được log. Đặc biệt với tool destructive.
- Test: gọi tool 2 lần liên tiếp cùng input, kiểm xem có ra hành vi đúng với
idempotentHintđã khai.
10 mục này không khó, nhưng nếu skip thì lúc deploy lên client thực tế (Claude Desktop, Cursor, custom agent) sẽ thấy ngay: tool hoặc không bao giờ được model chọn, hoặc bị model gọi sai cách, hoặc user complain vì bị prompt mỗi call.
Cá nhân hoá quan điểm
Sau khi build vài chục tool MCP, tôi rút lại một quan điểm hơi mạnh: tool design quan trọng hơn tool implementation. Bug trong implementation thì fix một lần là xong, bug trong design thì gãy mỗi conversation. Đó là lý do tôi dành nhiều thời gian cho description và schema constraint hơn cho business logic, đặc biệt với tool có side effect.
Bài kế đến đi qua Prompts và Sampling. Tool là phần khó nhất trong ba entity vì LLM chủ động gọi không qua user. Vượt qua Tool rồi, hai cái còn lại dễ thở hơn nhiều.
Sources tham khảo cho bài này: