Định nghĩa Tools bằng decorator

Xây dựng serverTrung cấp35 phút

Trước năm 2024, nếu bạn muốn cho Claude sử dụng tool, bạn phải viết một JSON schema như thế này:

Bạn sẽ học được
  • Khởi tạo một MCP server bằng FastMCP chỉ với 2 dòng code
  • Định nghĩa tool qua decorator @mcp.tool() với type hints + Field descriptions
  • Viết mô tả tool chất lượng — thứ quyết định 80% chất lượng tool của bạn
  • Xử lý lỗi và validation đúng cách trong tool implementation
  • Viết tool đầu tay: read_doc_contents và edit_document
  • Hiểu vì sao "designing tools for LLM" khác với "designing REST API"

Setup FastMCP — 3 dòng đầu tiên

Mọi MCP server Python bắt đầu với 3 dòng này:

Giải thích:

Sau dòng này, bạn đã có mcp object sẵn sàng nhận decorator tool/resource/prompt.

  • FastMCP — Class abstraction chính. Tên "Fast" là ode to FastAPI, không phải về speed.
  • "DocumentMCP" — Tên server. Xuất hiện trong handshake với client. Chọn tên có ý nghĩa, không generic như "Server".
  • log_level="ERROR" — Giảm log spam. Dùng "DEBUG" khi troubleshoot.
  • Field từ Pydantic — thêm description cho tham số. Đây là cách SDK biến Python type hints thành JSON schema MCP.
from mcp.server.fastmcp import FastMCP
from pydantic import Field

mcp = FastMCP("DocumentMCP", log_level="ERROR")

Dữ liệu mẫu: Document storage trong memory

Để không phân tâm vào database/filesystem, chúng ta sẽ lưu docs trong một dict đơn giản:

Key = document ID, value = content. Trong thực tế bạn có thể thay bằng SQLite, S3, hay REST API — pattern vẫn đúng.

docs = {
    "deposition.md": "This deposition covers the testimony of Angela Smith, P.E.",
    "report.pdf": "The report details the state of a 20m condenser tower.",
    "financials.docx": "These financials outline the project's budget and expenditures",
    "outlook.pdf": "This document presents the projected future performance of the system",
    "plan.md": "The plan outlines the steps for the project's implementation.",
    "spec.txt": "These specifications define the technical requirements for the equipment"
}

Tool đầu tiên: Read Document

Bóc tách từng phần

Decorator @mcp.tool(...)

Function signature

Body function

Flow khi Claude gọi tool

  • name — Tên tool Claude sẽ thấy. Dùng snake_case, descriptive. Không đặt tool1, read, hay tên quá generic.
  • description — Cực kỳ quan trọng. Đây là system prompt của tool. Model đọc description để quyết định khi nào gọi tool này.
  • doc_id: str — Type hint bắt buộc. SDK dùng type để generate JSON schema. Không type → không work.
  • = Field(description=...) — Mô tả tham số. Claude đọc cái này để biết truyền giá trị gì.
  • if doc_id not in docs: raise ValueError(...) — Error handling tự nhiên qua exception Python.
  • return docs[doc_id] — Return value được SDK serialize thành CallToolResult.
User: "What's in deposition.md?"
     │
     ▼
Claude API (với tool schema đã gen từ decorator):
     "I'll call read_doc_contents with doc_id='deposition.md'"
     │
     ▼
MCP client gửi CallToolRequest:
     { method: "tools/call",
       params: { name: "read_doc_contents",
                 arguments: { doc_id: "deposition.md" }}}
     │
     ▼
Server dispatch → gọi read_document(doc_id="deposition.md")
     │
     ▼
Return: "This deposition covers the testimony of Angela Smith, P.E."
     │
     ▼
MCP server wrap thành CallToolResult → client → Claude → User
@mcp.tool(
    name="read_doc_contents",
    description="Read the contents of a document and return it as a string."
)
def read_document(
    doc_id: str = Field(description="Id of the document to read")
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id {doc_id} not found")

    return docs[doc_id]

Tool thứ hai: Edit Document

Điểm khác biệt đáng chú ý

  • 3 tham số, mỗi cái có description riêng — Đây là pattern điển hình: doc_id + old_str + new_str cho find-and-replace.
  • Description tham số old_str có nhắc "Must match exactly, including whitespace" — Đây là prompt engineering. Không có dòng này, Claude có thể truyền text đã strip whitespace, khiến replace fail.
  • Side effect — Tool này thay đổi docs dict. Trong production, thay bằng DB commit.
  • Không return value tường minh — Python return None. MCP client nhận CallToolResult với content rỗng. Claude hiểu tool đã chạy thành công (không có error).
@mcp.tool(
    name="edit_document",
    description="Edit a document by replacing a string in the documents content with a new string."
)
def edit_document(
    doc_id: str = Field(description="Id of the document that will be edited"),
    old_str: str = Field(description="The text to replace. Must match exactly, including whitespace."),
    new_str: str = Field(description="The new text to insert in place of the old text.")
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id {doc_id} not found")

    docs[doc_id] = docs[doc_id].replace(old_str, new_str)

description là system prompt của tool — Cách viết tốt

Đây là phần quan trọng nhất bài này. Michael Cohen (Anthropic) nói thẳng:

Một tool với description tệ sẽ bị Claude bỏ qua hoặc gọi sai cách. Một tool với description tốt như có một senior engineer giải thích cách dùng.

Pattern TỆ vs TỐT

❌ TỆ:

Vấn đề: Model không biết:

Kết quả (case thật từ John Welsh): Claude truyền prompt "cute puppy" và bị Dall-E trả ảnh sai style.

✅ TỐT:

  • Dùng diffusion model nào? (SDXL? DALL-E? Midjourney?)
  • Prompt theo style nào? (tags comma-separated? natural sentence?)
  • Resolution? Aspect ratio?
  • Return URL hay binary?
@mcp.tool(name="gen_image", description="Generates an image")
def generate_image(prompt: str): ...

Pattern TỆ vs TỐT

Tại sao TỐT hơn:

6 nguyên tắc viết description tốt

1. Nói về "when to use", không chỉ "what it does"

❌ "Get user by ID." ✅ "Get full user profile when you need email, name, role, or preferences. Don't use this for just checking if a user exists—use user_exists() instead for that."

2. Example bên trong description

❌ "Format a date string" ✅ "Format a date string. Input ISO 8601 like '2026-04-19'. Output human-readable like 'April 19, 2026'."

3. Nói về edge case

❌ "Find products matching query" ✅ "Find products matching query. Returns empty list if no match—don't treat as error. Max 100 results."

4. Làm rõ parameter semantics

❌ limit: int = Field(description="Limit") ✅ limit: int = Field(description="Max number of items to return, 1-100. Defaults to 20 if not specified.")

5. Mention cách chaining với tool khác

❌ "Delete a file" ✅ "Delete a file. Call list_files() first to confirm the file exists. Returns confirmation string or raises FileNotFoundError."

6. Ngôn ngữ nhất quán

Nếu tool dùng "document", các tool khác liên quan cũng dùng "document" — không mix với "file", "doc", "record".

  • Model biết engine cụ thể → không đoán
  • Ví dụ cụ thể giúp model bắt chước format
  • Anti-examples giúp tránh failure mode
  • Return format rõ ràng → Claude biết xử lý thế nào sau đó
@mcp.tool(
    name="gen_image",
    description="""Generate an image using SDXL 1.0.

Prompts should be comma-separated phrases describing subject,
style, lighting, composition. Use photographic terms when realistic,
art movement names when artistic.

Examples:
  - "mountain landscape at dusk, oil painting, impressionist style,
     warm tones, rule of thirds"
  - "portrait of a cat wearing glasses, photorealistic, studio
     lighting, 85mm lens"

Avoid: full sentences, multiple unrelated subjects, celebrity names.

Returns: URL valid for 24 hours. High-res 1024×1024 by default."""
)
def generate_image(prompt: str = Field(
    description="SDXL prompt, comma-separated phrases, <200 tokens"
)): ...

Bảng so sánh: Thinking REST vs Thinking for LLM

John Welsh (Anthropic) nhấn mạnh: "Tools cho LLM nên giống natural language API hơn là REST API." Nếu người chưa biết API của bạn đọc description và hiểu cách dùng → model cũng hiểu.

Tiêu chíREST API thinkingLLM tool thinking
GranularityNhiều endpoint nhỏ, 1:1 với resourceÍt tool broader, cover nhiều intent
NamingNoun-heavy (/users/:id)Verb-heavy (get_user_info)
DescriptionDocstring ngắn, link docsPrompt-style, có example + anti-example
ParametersFlags cứng nhắc, enumField với semantic description
ErrorHTTP status codeException Python + clear message
Ai dùng?Frontend dev biết chính xác endpoint cầnLLM đoán từ description
Fuzzy input?Trả 400Tool handle gracefully + gợi ý

Ví dụ thực chiến: Build document server đầy đủ

Tình huống

Bạn đang build MCP server cho legal team xử lý tài liệu. Cần: read, edit, list, search.

Bước 1: Khung server (~1 phút)

Bước 2: Tool read_doc_contents (~2 phút)

# mcp_server.py
from mcp.server.fastmcp import FastMCP
from pydantic import Field

mcp = FastMCP("DocumentMCP", log_level="ERROR")

docs = {
    "deposition.md": "Angela Smith testimony...",
    "contract.pdf": "Service agreement between...",
    # ...
}

Bước 2: Tool read_doc_contents (~2 phút)

Bước 3: Tool edit_document (~2 phút)

@mcp.tool(
    name="read_doc_contents",
    description="""Read the full text contents of a document by its ID.

Use this when the user asks about what's in a specific document or
wants you to analyze, summarize, or quote from it.

The doc_id must match exactly—call list_docs() first if unsure.
Returns the raw document text as a string."""
)
def read_document(
    doc_id: str = Field(description="Exact document ID, e.g. 'deposition.md'")
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id '{doc_id}' not found. Call list_docs() to see available IDs.")
    return docs[doc_id]

Bước 3: Tool edit_document (~2 phút)

Bước 4: Entry point (~30 giây)

@mcp.tool(
    name="edit_document",
    description="""Edit a document by replacing exact text with new text.

Use this when the user asks to change, update, correct, or replace
specific text in a document.

Important: old_str must match EXACTLY including whitespace and case.
For safer edits, read the document first to get the exact text."""
)
def edit_document(
    doc_id: str = Field(description="Exact document ID to edit"),
    old_str: str = Field(description="Text to find and replace. Must match exactly, case-sensitive."),
    new_str: str = Field(description="Replacement text. Can be empty string to delete old_str.")
):
    if doc_id not in docs:
        raise ValueError(f"Doc with id '{doc_id}' not found")
    if old_str not in docs[doc_id]:
        raise ValueError(f"Text not found in document. Check for exact match including whitespace.")
    docs[doc_id] = docs[doc_id].replace(old_str, new_str)

Bước 4: Entry point (~30 giây)

Kết quả

File mcp_server.py ~50 dòng Python, 2 tool production-grade. So với tool use thuần (không MCP), tiết kiệm ~30 dòng JSON schema + ~20 dòng dispatcher code. Và tool này chạy ngay được với bất kỳ MCP client nào (Claude Desktop, Cursor, custom).

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

Ví dụ theo ngành — Tools thực tế đang ship

⚖️ Legal — Document review

Tool: read_contract(contract_id), flag_unusual_clauses(contract_id), compare_versions(v1, v2)

Description lý tưởng: "Flag clauses that deviate from standard templates. Focus on indemnity, termination, and liability sections. Return list of {clause_section, concern, severity}."

Kết quả thực tế: Legal team Anthropic (nguồn: claude__20251208) dùng pattern này tự động hóa contract review ban đầu, chỉ escalate những contract có clause bất thường.

💼 Sales — CRM integration

Tool: get_account(domain), log_call(account_id, notes), suggest_next_action(account_id)

Description lý tưởng: "Get full account context including recent interactions, open deals, and last touchpoint. Use before any outreach to avoid duplicate follow-up."

Kết quả thực tế: Sales rep prep cho call trong 5 phút thay vì 30 phút lục CRM.

📊 Data — Analytics query

Tool: run_sql(query, database), describe_table(table_name), sample_data(table_name, n)

Description lý tưởng: "Run read-only SQL. Writes are blocked. Use describe_table first if unsure about schema. Queries over 30s will timeout."

Kết quả thực tế: Non-engineer (product manager, CS lead) tự query database qua chat.

🏥 Healthcare — Patient records

Tool: get_patient_timeline(patient_id), flag_drug_interactions(medications), search_trials(criteria)

Description lý tưởng: "Aggregate labs, imaging, notes for a single patient chronologically. De-identified by default. Only flags drug interactions ≥ moderate severity."

Kết quả thực tế (nguồn: AbbVie case study, claude__20251020): Clinical team aggregate patient data 5 phút thay vì 30 phút.

🏢 Real Estate — Listing analysis

Tool: get_comps(address, radius_miles), market_trend(zip_code), generate_listing_desc(property)

Description lý tưởng: "Return comparable sold properties in past 6 months. Filter by similar sqft ±15% and beds/baths count."

Kết quả thực tế: Agent tạo listing presentation cá nhân hóa trong 30 phút thay vì 3-4 giờ.

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

❌ Description lười một dòng

Sai lầm:

Tại sao là sai: Claude không biết tìm gì, không biết input format, không biết output shape.

Cách đúng: Tối thiểu 2-3 câu description, có example, có edge case.

❌ Nhiều tool overlap

Sai lầm:

@mcp.tool(name="search", description="Search things")

❌ Nhiều tool overlap

Tại sao là sai: Model bối rối chọn cái nào. David Soria Parra: "If you put three issue-checker MCP servers next to each other, of course the model can get confused."

Cách đúng: 1 tool rõ ràng. Nếu 2 tool khác nhau đủ, đặt tên phản ánh sự khác biệt (get_doc_preview vs get_doc_full).

❌ Type hint thiếu hoặc dùng Any

Sai lầm:

@mcp.tool(name="get_doc")
@mcp.tool(name="read_doc")
@mcp.tool(name="fetch_doc")
@mcp.tool(name="retrieve_doc")
# 4 tool cùng làm 1 việc

❌ Type hint thiếu hoặc dùng Any

Tại sao là sai: SDK không gen đúng schema. Claude không biết truyền kiểu gì.

Cách đúng: Type hint cụ thể cho MỌI tham số. Dùng Optional[str] thay vì Any.

❌ Tool thực thi lâu không async

Sai lầm:

def search(query, limit):  # no type
    ...

def create_user(data: Any):  # Any defeats the purpose
    ...

❌ Tool thực thi lâu không async

Tại sao là sai: Block event loop, timeout client.

Cách đúng: Dùng async def:

@mcp.tool(...)
def generate_report(topic: str):
    # Block 60 giây
    time.sleep(60)
    return "done"

Anti-patterns — Những sai lầm cần tránh (tiếp)

❌ Trả data nhạy cảm trong tool result

Sai lầm:

@mcp.tool(...)
async def generate_report(topic: str):
    await asyncio.sleep(60)
    return "done"

❌ Trả data nhạy cảm trong tool result

Tại sao là sai: Data leak. Claude có thể echo lại trong response.

Cách đúng: Filter field nhạy cảm server-side TRƯỚC khi return.

❌ Silent failure

Sai lầm:

@mcp.tool(name="get_user")
def get_user(user_id: str):
    return db.query(f"SELECT * FROM users WHERE id={user_id}")
    # Trả cả password hash, SSN, v.v.

❌ Silent failure

Tại sao là sai: Claude nghĩ đã xóa thành công. User mất data thầm lặng.

Cách đúng: raise ValueError("Item not found") với message rõ. MCP client nhận error, Claude báo user.

❌ Mutable state không bảo vệ concurrent

Sai lầm:

@mcp.tool(...)
def delete_item(item_id: str):
    try:
        db.delete(item_id)
    except:
        pass  # swallow error
    return "ok"

❌ Mutable state không bảo vệ concurrent

Tại sao là sai: Nhiều client gọi cùng lúc → lost writes.

Cách đúng: Dùng lock (asyncio.Lock()) hoặc persistent store (SQLite, Redis) cho production.

docs = {}

@mcp.tool(...)
def add_doc(doc_id, content):
    docs[doc_id] = content  # race condition

Mẹo nâng cao

Mẹo 1: Few-shot examples trong description

Khi tool có pattern input phức tạp, nhét 2-3 ví dụ ngay trong description:

Mẹo 2: Annotations cho destructive tool

MCP spec hỗ trợ annotation đánh dấu tool nào mutates state:

description="""Parse a date range from natural language.

Examples:
  - "last week" → {"start": "2026-04-07", "end": "2026-04-13"}
  - "Q1 2026" → {"start": "2026-01-01", "end": "2026-03-31"}
  - "since jan" → {"start": "2026-01-01", "end": "today"}"""

Mẹo 2: Annotations cho destructive tool

Client (VD: Claude Desktop) có thể gate những tool này qua UI confirm, tăng safety.

Mẹo 3: Versioning tool

Khi schema thay đổi breaking:

@mcp.tool(
    name="delete_all_docs",
    description="Permanently delete ALL documents. Requires confirmation.",
    annotations={"destructive": True, "requires_confirmation": True}
)
def delete_all_docs(confirm_token: str):
    ...

Mẹo 3: Versioning tool

Giữ cả search_v1 và search_v2 cho clients legacy, deprecate dần.

Mẹo 4: Structured output with Pydantic

Return Pydantic model để output có shape rõ ràng:

@mcp.tool(name="search_v2", ...)  # old: search_v1
def search(query: str, filters: dict): ...

Mẹo 4: Structured output with Pydantic

SDK tự serialize thành JSON. Claude parse dễ hơn text raw.

from pydantic import BaseModel

class SearchResult(BaseModel):
    items: list[dict]
    total_count: int
    query_time_ms: int

@mcp.tool(...)
def search(query: str) -> SearchResult:
    ...
    return SearchResult(items=[...], total_count=42, query_time_ms=120)

Áp dụng ngay

Bài tập 1: Viết server đầu tay (~20 phút)

Bước 1: Trong project đã setup ở Bài 7.3, tạo file mcp_server.py.

Bước 2: Copy skeleton:

Bước 3: Copy docs = {...} từ bài.

Bước 4: Viết read_document tool với description đầy đủ (ít nhất 3 câu).

Bước 5: Viết edit_document tool với 3 tham số.

Bước 6: Ghi lại:

Bài tập 2 (thử thách): Tool thứ 3 (~15 phút)

Thêm tool list_documents trả về danh sách doc_id + preview 50 ký tự đầu mỗi doc.

Gợi ý:

Ghi lại:

Bài tập 3 (suy ngẫm): Review description

Mở mã nguồn của 1 MCP server open-source bất kỳ (VD: filesystem, git, sqlite). Đọc 5 tool descriptions đầu tiên.

Tự hỏi:

  • Thời gian hoàn thành: ___________
  • Tool description dài bao nhiêu câu: ___________
  • Bạn có thêm validation nào không có trong code mẫu? ___________
  • Return kiểu gì? list[dict]? Pydantic model?
  • Description nên mention: khi nào nên gọi list trước khi read/edit
  • Có cần param filter không? (optional prefix: str = None?)
  • Description bạn viết: ___________
  • Bạn có dùng Pydantic model không? ___________
  • Tool này khác read_doc_contents ở đâu về semantic?
  • Description nào rõ ràng nhất? Tại sao?
  • Description nào bạn thấy có thể cải thiện? Viết version tốt hơn.
  • Pattern nào lặp lại giữa nhiều tool? (VD: examples inline, error case spelled out, etc.)
from mcp.server.fastmcp import FastMCP
from pydantic import Field

mcp = FastMCP("MyFirstMCP", log_level="ERROR")

# TODO: thêm data store
# TODO: thêm read tool
# TODO: thêm edit tool

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

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

🎯 3 dòng khởi tạo FastMCP — from mcp.server.fastmcp import FastMCP; mcp = FastMCP("Name"). Decorator pattern y Flask.

🎯 Tool = function + decorator — @mcp.tool(name, description) + type hints + Field(description=...). Không JSON schema. Không dispatcher.

🎯 Description là 80% chất lượng — Michael Cohen: "MCP servers and tools are really a discipline of prompting." Đầu tư viết description = đầu tư vào chất lượng.

🎯 Design cho LLM, không cho REST — Broader tool với fuzzy description > 40 endpoint granular. Model handle fuzziness, REST thì không.

🎯 Error handling tự nhiên qua exception — raise ValueError("...") với message rõ ràng. SDK wrap thành CallToolResult với isError=True.

Tài liệu tham khảo
  • FastMCP documentation
  • MCP spec — Tools
  • Pydantic Field docs
  • "Building with MCP and the Claude API" talk, 10/2025
Nội dung này có hữu ích không?