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
inspect.signature(func)enumerates the parameters.typing.get_type_hints(func, include_extras=True)resolves the annotations (includingAnnotated[...]metadata).docstring_parser.parse(func.__doc__)pulls per-parameter descriptions out of Google / NumPy / Sphinx-style docstrings.pydantic.create_model()builds a dynamic model whose fields match the parameters.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
*argsor**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 xexcept 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:
- Another registered tool’s output schema has a field with the same name, or
- 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 Annotatedfrom 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 Annotatedfrom 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.