Guide · Pydantic · Python MCP

Pydantic MCP server validation — BaseModel schemas, validators, and error handling

FastMCP uses Pydantic v2 under the hood: Python type annotations on tool functions are resolved via Pydantic's type system, and BaseModel subclasses used as parameter types are converted to JSON schema automatically via model_json_schema(). This gives you Pydantic's entire validation library — field constraints, cross-field validators, discriminated unions, custom types — as your MCP tool input validation layer. When validation fails, FastMCP converts the ValidationError to an isError: true tool result that the LLM can read and correct without crashing the session. This guide covers the validation patterns most useful for MCP tools specifically.

TL;DR

Define a Pydantic BaseModel subclass and use it as the type annotation of a tool parameter. FastMCP calls model_json_schema() on it to generate the tool's input schema. Use Field() for constraints and descriptions, @field_validator for single-field validation, @model_validator(mode="after") for cross-field checks. When validation fails, ValidationError is caught by FastMCP and returned as isError: true with the error message — the LLM sees it and can retry with corrected values. For output, serialize with model.model_dump() before returning from the tool handler.

BaseModel as tool input schema

The simplest Pydantic integration is using a BaseModel as the type of a tool parameter. FastMCP recognizes Pydantic models and uses their JSON schema as the tool's inputSchema:

from pydantic import BaseModel, Field
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server")

class SearchParams(BaseModel):
    query: str = Field(..., description="Full-text search query")
    limit: int = Field(10, ge=1, le=100, description="Max results (1–100)")
    offset: int = Field(0, ge=0, description="Pagination offset")
    tags: list[str] = Field(default_factory=list, description="Filter by tags")

@mcp.tool()
async def search(params: SearchParams) -> list[dict]:
    """Search the knowledge base."""
    results = await db.search(params.query, params.limit, params.offset, params.tags)
    return [r.model_dump() for r in results]

The JSON schema generated from SearchParams will include the description strings from each Field(), the numeric constraints (ge, le), and the correct JSON types. The LLM reads this schema when deciding what arguments to pass to the tool.

Field(...) with ... (ellipsis) marks the field as required in the schema. Field(10, ...) with a default value makes it optional — the LLM can omit it and Pydantic fills in the default before validation.

Field constraints

Pydantic v2's Field() supports JSON Schema-compatible constraints that become part of the tool's input schema:

ConstraintTypeJSON Schema equivalent
min_length=5strminLength: 5
max_length=200strmaxLength: 200
pattern="^[a-z]+"strpattern: "^[a-z]+"
ge=0int/floatminimum: 0
le=100int/floatmaximum: 100
gt=0int/floatexclusiveMinimum: 0
lt=1000int/floatexclusiveMaximum: 1000
min_length=1 (list)listminItems: 1

These constraints appear in the inputSchema that the MCP client sends to the LLM. Well-constrained schemas help the LLM generate valid inputs on the first try, reducing validation error round-trips:

class FileOperation(BaseModel):
    path: str = Field(
        ...,
        min_length=1,
        max_length=500,
        pattern=r"^[^<>:\"\\|?*]+$",
        description="Relative file path within the workspace"
    )
    content: str = Field(
        ...,
        max_length=1_000_000,
        description="File content to write (max 1 MB)"
    )
    encoding: str = Field(
        "utf-8",
        pattern="^(utf-8|latin-1|ascii)$",
        description="File encoding: utf-8, latin-1, or ascii"
    )

Field validators

Use @field_validator for validation logic that cannot be expressed as a JSON Schema constraint — custom business rules, format normalization, or lookups against a registry:

from pydantic import BaseModel, Field, field_validator

class SlackMessage(BaseModel):
    channel: str = Field(..., description="Slack channel ID or name")
    message: str = Field(..., min_length=1, max_length=4000, description="Message text")
    thread_ts: str | None = Field(None, description="Thread timestamp to reply in")

    @field_validator("channel")
    @classmethod
    def normalize_channel(cls, v: str) -> str:
        # Strip leading # if the LLM includes it
        v = v.lstrip("#")
        # Validate format: channel IDs start with C, names are lowercase
        if not (v.startswith("C") or v.replace("-", "").replace("_", "").islower()):
            raise ValueError(f"Invalid channel format: {v!r}. Use the channel ID (C...) or lowercase name.")
        return v

    @field_validator("thread_ts")
    @classmethod
    def validate_thread_ts(cls, v: str | None) -> str | None:
        if v is not None and not v.replace(".", "").isdigit():
            raise ValueError(f"thread_ts must be a Slack timestamp like '1234567890.123456', got {v!r}")
        return v

Field validators run before the value is stored. If they raise ValueError or AssertionError, Pydantic collects the errors and raises a single ValidationError at the end of model initialization. FastMCP catches that and returns it as isError: true.

Cross-field validation with model_validator

Some validation rules involve multiple fields — a start date must be before an end date, a required field becomes optional when a flag is set, two alternative fields must have at least one populated. Use @model_validator(mode="after") for these:

from pydantic import BaseModel, Field, model_validator
from datetime import date

class DateRangeQuery(BaseModel):
    start_date: date = Field(..., description="Range start (inclusive)")
    end_date: date = Field(..., description="Range end (inclusive)")
    max_days: int = Field(90, ge=1, le=365, description="Maximum range size in days")

    @model_validator(mode="after")
    def validate_range(self) -> "DateRangeQuery":
        if self.end_date < self.start_date:
            raise ValueError(
                f"end_date ({self.end_date}) must be >= start_date ({self.start_date})"
            )
        delta = (self.end_date - self.start_date).days
        if delta > self.max_days:
            raise ValueError(
                f"Date range ({delta} days) exceeds max_days ({self.max_days}). "
                f"Narrow the range or increase max_days (up to 365)."
            )
        return self

mode="after" runs after all individual field validators have passed and the model is fully initialized — so self.start_date is already a date object, not a raw string. Use mode="before" if you need to transform raw input before per-field validation runs.

Discriminated unions for polymorphic inputs

When a tool accepts different shapes of input depending on a type discriminator, use Pydantic's discriminated union pattern. The LLM can inspect the union's type field and send the correct variant:

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class EmailAlert(BaseModel):
    type: Literal["email"] = "email"
    recipients: list[str] = Field(..., min_length=1, description="Email addresses")
    subject: str = Field(..., description="Email subject line")
    body: str = Field(..., description="Email body (plain text)")

class SlackAlert(BaseModel):
    type: Literal["slack"] = "slack"
    channel: str = Field(..., description="Slack channel ID")
    message: str = Field(..., max_length=4000, description="Message text")

class PagerAlert(BaseModel):
    type: Literal["pager"] = "pager"
    policy_id: str = Field(..., description="PagerDuty escalation policy ID")
    severity: str = Field("error", pattern="^(info|warning|error|critical)$")
    summary: str = Field(..., max_length=1024, description="Alert summary")

AlertInput = Annotated[
    Union[EmailAlert, SlackAlert, PagerAlert],
    Field(discriminator="type")
]

@mcp.tool()
async def send_alert(alert: AlertInput) -> dict:
    """Send an alert via email, Slack, or PagerDuty."""
    match alert:
        case EmailAlert():
            return await email.send(alert)
        case SlackAlert():
            return await slack.post(alert)
        case PagerAlert():
            return await pager.trigger(alert)

The generated JSON schema includes a oneOf with each variant, and each variant's schema has a const constraint on the type field. The LLM sees clearly that it must specify "type": "email" (or "slack" or "pager") to select the correct schema branch.

Nested models

Pydantic models can be arbitrarily nested. FastMCP generates the full nested JSON schema, including $defs references for reused sub-models:

class Address(BaseModel):
    street: str
    city: str
    country: str = Field("US", pattern="^[A-Z]{2}$", description="ISO 3166-1 alpha-2")
    postal_code: str

class Contact(BaseModel):
    name: str = Field(..., min_length=2)
    email: str = Field(..., pattern=r"^[^@]+@[^@]+\.[^@]+$")
    phone: str | None = None
    address: Address | None = None

class CreateOrderInput(BaseModel):
    customer: Contact
    items: list[str] = Field(..., min_length=1, description="SKU list")
    shipping_address: Address
    billing_address: Address | None = Field(
        None,
        description="Billing address, defaults to shipping address if omitted"
    )

@mcp.tool()
async def create_order(order: CreateOrderInput) -> dict:
    """Create a new order with customer and shipping details."""
    billing = order.billing_address or order.shipping_address
    result = await orders.create(order.customer, order.items, order.shipping_address, billing)
    return {"order_id": result.id, "status": result.status}

Structured output models

Pydantic is as useful for tool output as it is for input. Define output models to make tool responses consistent and to avoid returning raw dicts with inconsistent shapes:

class ToolResult(BaseModel):
    success: bool
    data: dict | None = None
    error: str | None = None
    metadata: dict = Field(default_factory=dict)

@mcp.tool()
async def get_user(user_id: str) -> dict:
    """Fetch user profile by ID."""
    try:
        user = await db.users.get(user_id)
        if user is None:
            return ToolResult(
                success=False,
                error=f"No user found with ID: {user_id}"
            ).model_dump()
        return ToolResult(
            success=True,
            data=user.model_dump(exclude={"password_hash", "mfa_secret"}),
            metadata={"fetched_at": datetime.utcnow().isoformat()}
        ).model_dump()
    except Exception as exc:
        raise RuntimeError(f"Database error fetching user {user_id}: {exc}") from exc

Returning a structured model via model_dump() gives the LLM a predictable shape to parse. The LLM can check result.success before reading result.data, which is more reliable than trying to infer success from an arbitrary dict structure.

ValidationError and isError:true

When a Pydantic ValidationError is raised during tool input parsing, FastMCP catches it and returns:

{
  "content": [{
    "type": "text",
    "text": "1 validation error for SearchParams\nlimit\n  Input should be less than or equal to 100 [type=less_than_equal, input_value=500, input_url=...]"
  }],
  "isError": true
}

The isError: true flag tells the LLM that the tool call failed due to a client-side error (wrong input) rather than a server-side error. The LLM can read the Pydantic error message — which includes the field name, the violation, and the received value — and retry with corrected input.

Write validation error messages that give the LLM enough information to self-correct on the first retry. Include the constraint, the received value, and what the correct range or format should be. Pydantic's default messages are usually sufficient, but you can customize them with Field(description="...") and clear ValueError messages in validators.

Related questions

Can I use Pydantic v1 models with FastMCP?

FastMCP requires Pydantic v2. Pydantic v1 models use a different API (.schema() vs .model_json_schema(), @validator vs @field_validator). If your project uses Pydantic v1, upgrade to v2 — it's the current stable version and the migration guide covers the decorator changes. FastMCP won't recognize Pydantic v1 models as schemas and will fall back to treating them as opaque object types.

Should I validate inside the tool handler or rely entirely on Pydantic?

Rely on Pydantic for input shape and constraint validation. Use handler-level checks for business-rule validation that requires a database lookup or external call — things like "does this user ID exist in the database" cannot be expressed as a Pydantic validator without coupling your schema to your data layer. Raise ValueError or a custom exception from the handler for these cases; FastMCP returns them as isError: true the same way it handles ValidationError.

How do I use Pydantic with the lower-level MCP Server class (not FastMCP)?

Call YourModel.model_json_schema() and use the result as the inputSchema in your Tool registration. In your call_tool handler, parse the incoming arguments dict with YourModel.model_validate(args) — this raises ValidationError on invalid input. You must catch and handle ValidationError yourself and return an appropriate isError: true result; FastMCP does this automatically.

Further reading