Skip to main content
Lesson 11 of 14 · MCP for Developers: Servers and Integrations

Implementing Tools: JSON Schema and Handlers

Lesson 11 of 14 Updated November 2, 2026 Developer
On this page
  1. The Anatomy of an MCP Tool
  2. What the Decorator Builds for You
  3. Naming, Then Describing
  4. Types, Async Handlers, and Validation
  5. Polishing the Return Value
  6. Key Takeaways

Updated November 2, 2026 — MCP Python SDK v2. This course now follows version 2 of the mcp SDK (2.3.0 as of October 2, 2026): MCPServer replaces FastMCP, transport settings move to run() or streamable_http_app(), and an application exposed under a real hostname must declare that hostname (transport_security). This is also the version required by the Mistral SDK: the mistralai[agents] extra requires mcp>=2.2.0,<3.0. Sources: What’s new in v2 · Migration Guide · Deploy & scale.

The Anatomy of an MCP Tool

An MCP tool looks like a Python function, but from the model’s perspective, it isn’t one. It is a four-part contract: a name, a description, a JSON input schema, and a handler that executes the logic. The model never sees your code—it sees only this contract. A perfectly implemented tool that is poorly described will never be called, or it will be called at the wrong time. Therefore, the quality of the contract determines the server’s effectiveness far more than the quality of the implementation.

What the Decorator Builds for You

MCPServer builds this contract automatically from your function signature and docstring. You write standard Python:

from mcp.server.mcpserver import MCPServer

app = MCPServer("My Server")

@app.tool()
def search_documents(
    query: str,
    max_results: int = 10,
    language: str = "fr",
    include_archived: bool = False,
) -> str:
    """Searches for documents in the knowledge base.

    Args:
        query: Search terms (2-500 characters)
        max_results: Maximum number of results (1-100, default: 10)
        language: ISO 639-1 language code (default: fr)
        include_archived: Include archived documents (default: false)

    Returns:
        JSON with the list of found documents and the total count
    """
    # Search logic
    ...

And the model receives this:

{
  "name": "search_documents",
  "description": "Searches for documents in the knowledge base.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "Search terms (2-500 characters)"
      },
      "max_results": {
        "type": "integer",
        "description": "Maximum number of results (1-100, default: 10)",
        "default": 10
      },
      "language": {
        "type": "string",
        "description": "ISO 639-1 language code (default: fr)",
        "default": "fr"
      },
      "include_archived": {
        "type": "boolean",
        "description": "Include archived documents (default: false)",
        "default": false
      }
    },
    "required": ["query"]
  }
}

Compare the two blocks line by line: every type annotation becomes a JSON type, every default value becomes a default field, every line in the Args section becomes a parameter description, and the only argument without a default value ends up in required. In other words, anything you omit from the docstring disappears completely from the contract.

Naming, Then Describing

The tool name is the function name, and the model uses it as its first clue. An action verb followed by a clear object leaves no ambiguity, whereas a generic or technical name forces the model to guess:

# Good: action verb + clear object
@app.tool()
def create_issue(title: str, body: str) -> str: ...

@app.tool()
def search_repositories(query: str) -> str: ...

@app.tool()
def get_user_profile(username: str) -> str: ...

# Bad: vague, ambiguous, technical
@app.tool()
def process(data: str) -> str: ...        # Too vague

@app.tool()
def handle_request(req: str) -> str: ...   # Incomprehensible to the LLM

@app.tool()
def db_query(sql: str) -> str: ...         # Too technical

The docstring then takes over and constitutes the most critical piece of your entire server. It must answer three questions in this order: when to use this tool, which parameters to provide with their format and constraints, and what to expect in return. The following example shows what this looks like in a real-world case:

@app.tool()
def book_restaurant(
    restaurant_name: str,
    date: str,
    party_size: int,
    special_requests: str = "",
) -> str:
    """Books a table at a restaurant.

    Performs a complete reservation including email
    confirmation. Use this tool when the user wants
    to book a specific restaurant.

    Args:
        restaurant_name: Exact name of the restaurant
        date: Date and time in ISO 8601 format (e.g., 2026-04-15T19:30:00)
        party_size: Number of guests (1-20)
        special_requests: Special requests (allergies, occasion, etc.)

    Returns:
        JSON with the reservation number, confirmation,
        and restaurant details
    """
    ...

The paragraph following the first line is what amateur servers most often lack. Without it, the model knows what the tool does but not in which situation to trigger it. And the ISO 8601 date example will save you dozens of calls with "April 15 at 7:30 PM".

Types, Async Handlers, and Validation

MCPServer translates common Python annotations into their JSON Schema equivalents.

PythonJSON SchemaExample
strstring"text"
intinteger42
floatnumber3.14
boolbooleantrue
list[str]array of string["a", "b"]
dictobject{"key": "val"}
Optional[str]string (nullable)"text" or null

As soon as a tool steps outside its process—network call, database query, remote file read—declare it async. A synchronous handler waiting for an HTTP response blocks the entire server, and your other users along with it:

import httpx

@app.tool()
async def fetch_api_data(endpoint: str) -> str:
    """Retrieves data from an external API.

    Args:
        endpoint: The API endpoint path (e.g., /users/123)

    Returns:
        JSON data from the API
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.example.com{endpoint}")
        response.raise_for_status()
        return response.text

The JSON schema constrains types, never values: nothing prevents the model from sending a 3,000-character name or a made-up role. Therefore, always validate at the handler entry point, and return a message indicating the correction needed:

import json

@app.tool()
def create_user(name: str, email: str, role: str = "viewer") -> str:
    """Creates a new user.

    Args:
        name: Full name (2-100 characters)
        email: Valid email address
        role: Role: viewer, editor, admin (default: viewer)

    Returns:
        JSON with the created user details
    """
    # Validation
    if len(name) < 2 or len(name) > 100:
        return json.dumps({"error": "Name must be between 2 and 100 characters"})

    if "@" not in email or "." not in email:
        return json.dumps({"error": "Invalid email address"})

    valid_roles = ("viewer", "editor", "admin")
    if role not in valid_roles:
        return json.dumps({"error": f"Invalid role. Choices: {', '.join(valid_roles)}"})

    # Creation...
    return json.dumps({"success": True, "user": {"name": name, "email": email, "role": role}})

The third message lists the allowed values. The model can then correct itself and call the tool again, instead of giving up in the face of an “Invalid role” error with no explanation.

Polishing the Return Value

An MCP tool always returns text, but that text can be structured JSON, which the model interprets very well. The contrast between these two returns sums up the essentials:

# Good: structured, readable JSON
return json.dumps({
    "success": True,
    "user": {
        "name": "Alice Dupont",
        "email": "[email protected]",
        "role": "editor",
    },
    "message": "User created successfully"
}, ensure_ascii=False)

# Bad: raw data that is hard to use
return "OK:user_id=abc123:status=created"

Always prefer values readable by a human over internal identifiers: the model can immediately use "Alice Dupont" in its response, whereas a UUID forces an additional call to find out who it is talking about. Conversely, beware of bloated returns—fifty fields per record fill the context without adding value. The right reflex when writing a tool is to ask yourself what the model will need to tell the user, and return only that.

Key Takeaways

  • The @app.tool() decorator automatically generates the JSON Schema for parameters
  • The function name = tool name (verb + object)
  • The docstring is crucial: it guides the model on when and how to call the tool
  • Use Python type annotations for the schema (str, int, float, bool, list, dict)
  • Always validate inputs and return actionable errors
  • Return structured JSON with human-readable values