Skip to content

Schema generation

The SDK converts your function signature into an MCP-compliant JSON Schema at decoration time. The implementation lives in platools/core/schema.py.

Strategy

  1. inspect.signature(func) enumerates the parameters.
  2. typing.get_type_hints(func, include_extras=True) resolves the annotations (including Annotated[...] metadata).
  3. docstring_parser.parse(func.__doc__) pulls per-parameter descriptions out of Google / NumPy / Sphinx-style docstrings.
  4. pydantic.create_model() builds a dynamic model whose fields match the parameters.
  5. model.model_json_schema() emits the final schema dict.

The generated schema’s outer title is stripped so the model name doesn’t leak into the UI. $defs from nested models are preserved.

SchemaError

Raised at decoration time when:

  • A parameter has no type hint.
  • The function uses *args or **kwargs.
  • get_type_hints() can’t resolve an annotation (usually a forward ref to a module that hasn’t been imported).
from platools import SchemaError
try:
@platools.tool()
def oops(x): # no type hint
return x
except SchemaError as exc:
print(exc) # "oops.x has no type hint - every tool parameter must be typed"

Descriptions via docstrings

Docstring descriptions are merged into the generated schema automatically:

@platools.tool()
def list_orders(customer_id: str, limit: int = 10) -> list[Order]:
"""Return the most recent orders for a customer.
Args:
customer_id: Opaque UUID of the customer whose orders to fetch.
limit: Maximum number of orders to return. Defaults to 10.
"""
...

The resulting input_schema will look roughly like:

{
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "Opaque UUID of the customer whose orders to fetch."
},
"limit": {
"type": "integer",
"description": "Maximum number of orders to return. Defaults to 10.",
"default": 10
}
},
"required": ["customer_id"]
}

docstring_parser handles Google, NumPy, and Sphinx styles - write the one your team already uses.

x-user-providable - satisfying the doctor param-source check

platools doctor runs check_param_sources (PRD §5.1 rule 1) on every registered tool. A required input param is considered “reachable” when either:

  1. Another registered tool’s output schema has a field with the same name, or
  2. The param’s JSON Schema is marked "x-user-providable": true.

Tools that take free-form user input (customer IDs, free text, enum choices, etc.) need the x-user-providable marker on every required field. Inline it via Field(json_schema_extra=...):

from typing import Annotated
from pydantic import Field
_USER = {"x-user-providable": True}
@platools.tool()
def list_orders(
customer_id: Annotated[
str,
Field(description="Opaque customer ID.", json_schema_extra=_USER),
],
) -> list[Order]:
"""Return recent orders for a customer."""
...

Inline the Annotated[...] per parameter rather than sharing a module-level alias - FieldInfo instances are mutable and sharing one across multiple tools can cause descriptions to bleed between parameters.

Annotated[T, Field(...)]

For tighter constraints (min/max, regex, description override), use Annotated:

from typing import Annotated
from pydantic import Field
@platools.tool()
def search_products(
query: Annotated[str, Field(min_length=1, max_length=200, description="Free-text query")],
max_results: Annotated[int, Field(ge=1, le=100)] = 10,
) -> list[Product]:
"""Full-text search across the product catalog."""
...

The FieldInfo embedded in the Annotated metadata is passed through to Pydantic unchanged, so everything Field() supports (regex, ge/le, multipleOf, enum values) shows up in the JSON Schema.

If a docstring describes the same parameter and the Field() has no explicit description, the docstring wins - but an explicit Field(description=...) is never overwritten.

Output schemas

Return-type annotations become the tool’s output schema:

class RefundResult(BaseModel):
refund_id: str
amount_cents: int
@platools.tool()
def refund(order_id: str) -> RefundResult: ...

Under the hood the SDK wraps the return type in a synthetic __output model with a single result field, renders it to JSON Schema, then unwraps the result property so the final output_schema is just the return type’s schema (with $defs preserved for nested models).

Functions that return None (or have no return annotation) emit output_schema=None - doctor has a check for that if you want to enforce an output shape on every tool.

Inspecting generated schemas

from my_app.tools import platools
for schema in platools.get_mcp_schemas():
print(schema.name)
print(schema.input_schema)
print(schema.output_schema)

get_mcp_schemas() returns frozen ToolSchema dataclasses, safe to pass directly to anything that expects an MCP tool descriptor.

Next steps

  • Decorator - the full @platools.tool() keyword API.
  • Client - how schemas are sent to the platform over WebSocket.
  • CLI - platools doctor validates your schemas at ship time.