Manual schema viết vất vả:
- Dùng @mcp.tool decorator + Pydantic Field
- Auto-generate JSON schema từ type hints
- Implement tools read/edit documents
- Handle errors qua ValueError
Mở đầu: SDK làm hết việc nặng
{
"name": "read_doc",
"description": "...",
"input_schema": {
"type": "object",
"properties": {"doc_id": {"type": "string", "description": "..."}},
"required": ["doc_id"]
}
}
FastMCP SDK:
@mcp.tool(name="read_doc", description="...")
def read_document(doc_id: str = Field(description="...")):
...
SDK auto-gen schema từ type hints + Field.
Setup FastMCP
from mcp.server.fastmcp import FastMCP
from pydantic import Field
mcp = FastMCP("DocumentMCP", log_level="ERROR")
FastMCP = convenience wrapper cho building MCP servers quickly.
In-memory data
docs = {
"deposition.md": "This deposition covers Angela Smith's testimony.",
"report.pdf": "The report details a 20m condenser tower inspection.",
"financials.docx": "Q4 budget breakdown and expenditures.",
"outlook.pdf": "Future performance projections.",
"plan.md": "Project implementation steps.",
"spec.txt": "Technical requirements for equipment."
}
Simple dict. Production: DB, filesystem, API.
Tool 1: Read document
@mcp.tool(
name="read_doc_contents",
description="Read contents of a document and return as string."
)
def read_document(
doc_id: str = Field(description="ID of the document to read")
) -> str:
if doc_id not in docs:
raise ValueError(f"Doc '{doc_id}' not found. Available: {list(docs.keys())}")
return docs[doc_id]
Claude generates schema:
{
"name": "read_doc_contents",
"description": "Read contents of a document and return as string.",
"input_schema": {
"type": "object",
"properties": {
"doc_id": {"type": "string", "description": "ID of the document to read"}
},
"required": ["doc_id"]
}
}
Auto.
Tool 2: Edit document
@mcp.tool(
name="edit_document",
description="Edit a document by replacing old_str with new_str. old_str must match exactly."
)
def edit_document(
doc_id: str = Field(description="ID of the document to edit"),
old_str: str = Field(description="Text to replace. Must match exactly."),
new_str: str = Field(description="Replacement text.")
) -> str:
if doc_id not in docs:
raise ValueError(f"Doc '{doc_id}' not found")
if old_str not in docs[doc_id]:
raise ValueError(f"Text '{old_str}' not found in document")
docs[doc_id] = docs[doc_id].replace(old_str, new_str)
return f"Edited {doc_id}: replaced '{old_str[:30]}...' with '{new_str[:30]}...'"
Return confirmation string. Claude parse và relay.
Run server
if __name__ == "__main__":
mcp.run(transport="stdio")
Start: uv run mcp_server.py (runs as stdio, waits for client).
Test via Inspector: mcp dev mcp_server.py.
Pydantic Field niceties
Default value
@mcp.tool()
def search_docs(
query: str = Field(description="Search query"),
limit: int = Field(default=10, description="Max results")
):
...
Constraint
@mcp.tool()
def create_task(
title: str = Field(description="Task title", min_length=3, max_length=100),
priority: int = Field(description="1-5", ge=1, le=5)
):
...
Enum
from typing import Literal
@mcp.tool()
def set_status(
status: Literal["pending", "active", "done"] = Field(description="Task status")
):
...
All auto-converted to JSON schema.
Complex input types
List
@mcp.tool()
def batch_create(
titles: list[str] = Field(description="Titles to create")
):
...
Nested object (Pydantic model)
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
zip: str
@mcp.tool()
def create_customer(
name: str,
address: Address
):
...
FastMCP handles nested types.
Error handling
Tool error → MCP server send isError: true automatically khi you raise:
@mcp.tool()
def buggy_tool(x: int):
if x < 0:
raise ValueError("x must be >= 0")
return f"Got {x}"
Claude receives error message, có thể retry với corrected input.
Tool testing
Via Inspector
Fastest iteration.
Via pytest
import pytest
from mcp_server import read_document, edit_document
def test_read():
result = read_document(doc_id="report.pdf")
assert "20m condenser" in result
def test_read_missing():
with pytest.raises(ValueError):
read_document(doc_id="nonexistent.pdf")
def test_edit():
result = edit_document(doc_id="report.pdf", old_str="report", new_str="document")
assert "Edited" in result
Unit tests independent of MCP protocol.
Best practices
1. Descriptive tool names
read_doc_contents > get_doc > doc
2. Rich descriptions
description="""Read contents of a document stored in the system.
Use when user asks about specific file content or wants full text.
Returns plain text string. Raises error if doc not found."""
3-4 sentences. Claude learn from description.
3. Clear Field descriptions
Every param = description. Examples help:
doc_id: str = Field(description="ID like 'report.pdf' or 'plan.md'")
4. Consistent naming
doc_id, file_id, user_id — consistent suffix.
Anti-patterns
Tool names too generic
query, get, run → Claude confused which tool for what.
Fix: Specific: search_emails, get_order_status, run_sql_query.
Tool does too many things
manage_documents(action, doc_id, new_content) → Claude không biết action values.
Fix: Break into read_doc, edit_doc, delete_doc.
Missing type hints
@mcp.tool()
def search(query): # no type
...
Schema incomplete. Claude guess.
Fix: Always type hints: query: str.
Silent fail
if doc_id not in docs:
return None # Claude confused
Fix: raise ValueError("specific message").
Áp dụng ngay
Bài tập 1: Implement 2 tools (20 phút)
Complete read_doc_contents và edit_document in mcp_server.py. Test via Inspector.
Bài tập 2: Add tool (30 phút)
Add list_documents() tool returning all doc IDs. Add search_documents(query: str) that fuzzy-matches query in doc content.
Test chaining: "List docs, then search for 'budget'".
Tóm tắt bài học
- @mcp.tool decorator + Pydantic Field = auto JSON schema.
- Type hints critical — drives schema generation.
- Raise ValueError cho error handling. Claude sees và retries.
- FastMCP handles: stdio transport, message routing, serialization.
- Descriptive names + rich descriptions = Claude picks right tool.