An MCP tool can appear in a client’s tool list and still fail on the first real call. The model sends limit: "ten" where your handler expects an integer. Or it omits a required ID. A good input schema catches those mistakes before your function touches a database or an external API.

In Python, you don’t have to write the schema by hand. The MCP Python SDK derives it from your function signature. That makes the signature part of your public interface, not just a hint for your editor. If you’re new to tool registration, start with the first Python MCP server guide; this guide focuses on tightening the contract after the basic server works.

Start with a typed tool

In a project with the official SDK installed, create server.py:

from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

@mcp.tool()
def search_books(query: str, limit: int = 10) -> str:
    """Search the book catalog by title or author."""
    return f"Search for {query!r}, returning at most {limit} books."

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

The example returns a message rather than querying a real catalog. That keeps the boundary visible: query is required, limit is optional because it has a default, and both have declared types. During tools/list, the SDK exposes a JSON Schema for the tool’s arguments. A client can use that schema to show a form or decide how to construct a call. The server also validates arguments before it invokes the function.

If you already have a server built with FastMCP, the same principle applies to decorated Python functions. The current Python SDK tools documentation uses MCPServer in its examples. Follow the API for the SDK version actually installed in your project rather than mixing imports from two different examples.

Make optional mean optional

A type hint alone does not make an argument optional. limit: int without a default is required. limit: int = 10 can be omitted and receives 10. This distinction matters because an agent may see the word “optional” in a docstring but still receive a schema that requires the field.

Inspect the tool in the MCP Inspector:

uv run mcp dev server.py

Open the Tools tab and look at search_books. Confirm that query is marked required and limit is not. Call it once with {"query": "Dune"} and once with {"query": "Dune", "limit": 3}. If the form or schema does not match the function you meant to expose, fix the function signature before writing client prompts around it.

Put business limits in the contract

int prevents a string such as "ten" from reaching your handler, but it doesn’t express a sensible search range. A negative limit is still an integer. Use a Pydantic field constraint when the allowed range is part of the tool’s API:

from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

@mcp.tool()
def search_books(
    query: Annotated[str, Field(min_length=1, description="Title or author to search")],
    limit: Annotated[int, Field(ge=1, le=50, description="Maximum matches")] = 10,
) -> str:
    """Search the book catalog by title or author."""
    return f"Search for {query!r}, returning at most {limit} books."

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

Now the schema can communicate that the query must not be empty and the result cap belongs between 1 and 50. The handler still needs to implement the actual search. Validation cannot determine whether a title exists, whether a caller has permission to see a book, or whether a downstream API is available. Keep authorization and business checks inside the operation, even when the argument schema is tight.

Test failures deliberately

Try three calls in the Inspector: a valid query with limit: 5, an empty query, and a query with limit: 100. Then try a non-numeric limit. The first should reach the function. Invalid arguments should be rejected before the function runs; check the Inspector’s response rather than assuming every client presents errors the same way.

Do not log secrets or private input values just to prove validation worked. For a side-effecting tool, testing is even more important: mock the downstream write or use a disposable environment. A schema is not a permission system. The server must still authenticate the caller, check the requested action, and return a clear tool error when an otherwise valid call cannot be completed. The local MCP debugging guide covers the next layer of inspection.

Avoid two common schema traps

First, don’t call a parameter payload: dict when you know its fields. A broad dictionary leaves the client guessing what to send and moves every mistake into your handler. Define named, typed parameters or a structured model instead.

Second, don’t quietly change a tool’s required fields after clients depend on it. Renaming query to search_term changes the contract advertised by tools/list. Update callers and tests together. If the change cannot be coordinated, publish a new tool name and retire the old one deliberately.

This is the useful division of labor: the schema says what a valid call looks like; the handler says whether that valid call should succeed. Treat both as code you have to test.

FAQ

Does the MCP client validate inputs, or does the server? A client can read the advertised schema and guide calls, but don’t rely on client behavior for enforcement. The Python SDK validates arguments on the server before calling your typed function.

Can type hints replace authorization? No. They describe argument shapes, not who may run a tool or which records a caller may access. Check permissions in the server’s execution path.

What if my tool accepts a flexible JSON object? Define the fields you actually require and document the extension points. If you truly accept arbitrary keys, validate them inside the handler and explain the supported structure in the tool description. A vague schema pushes avoidable errors onto every client.