Skip to content

Tools & ToolAnnotations

Tools are executable operations exposed via tools/list and tools/call. Any Litestar route handler marked with mcp_tool=True (or decorated with @mcp_tool) is compiled into an MCP tool specification with a JSON Schema 2020-12 inputSchema and optional ToolAnnotations.

Internal ASGI Tool Execution (execute_tool)

When a client invokes tools/call, MCPHandlerService does not call the underlying Python function in isolation. Instead, execute_tool synthesizes an internal ASGI HTTP scope targeted at the route handler and runs the full Litestar handler lifecycle:

  1. Argument Validation: _validate_tool_arguments validates incoming params.arguments against the handler's parsed_fn_signature and msgspec type converters before dispatch.
  2. before_tool_call Hook: If configured on MCPConfig, before_tool_call runs with (tool_name, arguments, request).
  3. Guards: Route-, controller-, router-, and app-level Litestar guards execute against the synthesized Request (preserving request.user, request.auth, headers, and litestar-security SecurityContext).
  4. Dependency Injection: Litestar Provide dependencies, Dishka Scope.REQUEST (FromDishka[T]) dependencies, and standalone ToolContext / Inject() / Depends(fn) bindings resolve cleanly.
  5. Handler Execution & after_tool_call Hook: Sync and async handlers run, and after_tool_call receives the result, exception, and elapsed duration.

JSON Schema 2020-12 Synthesis

cached_schema_for_handler introspects the route handler's parsed_fn_signature and builds a JSON Schema 2020-12 ("https://json-schema.org/draft/2020-12/schema") inputSchema:

  • Scalar & Path/Query Parameters: Path, query, and scalar arguments become top-level properties in inputSchema. Parameters without default values are listed in required.
  • Annotated[T, Parameter(...)] Constraints: Litestar Parameter metadata (title, description, gt, ge, lt, le, min_length, max_length, pattern, examples) and custom mcp_header extensions are unwrapped and emitted directly on the property schema.
  • Structured Body Models (data): Handlers declaring a data parameter typed as a msgspec.Struct, @dataclass, TypedDict, pydantic.BaseModel, or attrs class have their model fields expanded into inputSchema. Callers can pass structured fields either nested under "data" or flattened at the top level of arguments.
  • DI Exclusion: Parameters satisfied by Litestar Provide dependencies, FromDishka[T], ToolContext, or framework reservations (request, state, headers, scope, cookies) are automatically excluded from inputSchema.

ToolAnnotations & Automatic HTTP Verb Inference

MCP ToolAnnotations provide behavioral hints (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, and scopes) to LLM clients and approval UIs.

When MCPConfig.infer_http_method_annotations is True (the default), litestar-mcp automatically infers baseline ToolAnnotations from the route handler's HTTP verb:

HTTP Method Inferred readOnlyHint Inferred destructiveHint Inferred idempotentHint
GET, HEAD True False True
PUT False (omitted) True
DELETE False True True
POST, PATCH False (omitted) (omitted)

Annotation Resolution Precedence

Explicit annotations merge over the HTTP-inferred defaults in the following order (later entries win):

  1. Inferred HTTP verb defaults (when MCPConfig.infer_http_method_annotations=True).
  2. @mcp_tool(annotations={...}) dictionary.
  3. Route opt mcp_annotations={...} dictionary.
  4. @mcp_tool(read_only=True | False) shorthand (sets annotations["readOnlyHint"]).
  5. Route opt mcp_read_only=True | False shorthand (sets annotations["readOnlyHint"]).
  6. @mcp_tool(scopes=[...]) (sets annotations["scopes"] if "scopes" was not already provided in annotations).
docs/examples/snippets/tool_annotations.py
"""Snippet: MCP ToolAnnotations, HTTP verb inference, and route opt overrides."""

# start-example
from litestar import Litestar, delete, get, post, put

from litestar_mcp import LitestarMCP, MCPConfig, mcp_tool


@get("/items", mcp_tool="list_items")
async def list_items() -> list[str]:
    """List items with inferred readOnlyHint=True, destructiveHint=False, idempotentHint=True."""
    return ["espresso", "flat_white"]


@post("/items/search", mcp_tool="search_items", mcp_read_only=True)
async def search_items(query: str) -> list[str]:
    """Search items via POST while overriding readOnlyHint=True."""
    return [query]


@put("/items/{item_id:str}", mcp_tool="replace_item")
async def replace_item(item_id: str) -> dict[str, str]:
    """Replace an item with inferred readOnlyHint=False and idempotentHint=True."""
    return {"id": item_id, "status": "replaced"}


@mcp_tool(
    "inspect_item",
    read_only=True,
    annotations={
        "title": "Inspect Catalog Item",
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
@get("/items/{item_id:str}/inspect")
async def inspect_item(item_id: str) -> dict[str, str]:
    """Inspect a catalog item with explicit decorator annotations."""
    return {"id": item_id}


@delete(
    "/items/{item_id:str}",
    mcp_tool="delete_item",
    mcp_annotations={
        "title": "Delete Catalog Item",
        "readOnlyHint": False,
        "destructiveHint": True,
        "idempotentHint": True,
        "openWorldHint": False,
    },
    status_code=200,
)
async def delete_item(item_id: str) -> dict[str, str]:
    """Delete an item with inferred destructiveHint=True merged with mcp_annotations."""
    return {"deleted": item_id}


config = MCPConfig(infer_http_method_annotations=True)
app = Litestar(
    route_handlers=[list_items, search_items, replace_item, inspect_item, delete_item],
    plugins=[LitestarMCP(config)],
)
# end-example


def build() -> Litestar:
    """Return the configured Litestar application for snippet smoke testing."""
    return app

Set MCPConfig(infer_http_method_annotations=False) if you want tools/list to include annotations only when explicitly declared via @mcp_tool or handler.opt.

Explicit Input Schemas (input_schema=)

When a tool requires a custom JSON Schema 2020-12 contract—such as additionalProperties: False, conditional oneOf / allOf clauses, or custom $defs—pass input_schema= (and optionally output_schema=) to @mcp_tool:

docs/examples/snippets/tool_explicit_input_schema.py
"""Tool with an explicit JSON Schema 2020-12 input contract."""

from litestar import get

from litestar_mcp import mcp_tool


# start-example
@mcp_tool(
    input_schema={
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {"region": {"type": "string"}},
        "required": ["region"],
        "additionalProperties": False,
    },
)
@get("/regions")
async def search_regions(region: str) -> list[str]:
    return [region]


# end-example

The explicit input_schema replaces signature inference for both tools/list discovery and SEP-2575 Mcp-Param-* header validation.

Pre-Task Interactive Input (task_input_before_start=True)

For task-capable tools (task_support="optional" or "required") that must collect interactive user confirmation or elicitation before a background task is created, set task_input_before_start=True (or mcp_task_input_before_start=True):

docs/examples/snippets/tool_task_input_before_start.py
"""Task-capable tool that completes an MRTR input round before task creation."""

from litestar import post

from litestar_mcp import MCPInputRequiredResult, get_mcp_request_context, mcp_tool


# start-example
@mcp_tool(
    task_support="required",
    task_input_before_start=True,
)
@post("/reports")
async def publish_report() -> MCPInputRequiredResult | dict[str, str]:
    context = get_mcp_request_context()
    if not context.input_responses:
        return MCPInputRequiredResult(
            input_requests={
                "approval": {
                    "method": "elicitation/create",
                    "params": {"message": "Publish this report?"},
                }
            },
            request_state="integrity-protected-state",
        )
    return {"status": "published"}


# end-example
  1. On the initial tools/call (where context.input_responses is None), the handler executes synchronously and returns MCPInputRequiredResult (resultType: "input_required") without allocating a task record.
  2. When the client retries tools/call supplying params.inputResponses and params.requestState, MCPHandlerService creates the background TaskRecord and dispatches execution with the supplied responses bound to get_mcp_request_context().input_responses.

Binary and Mixed Content Results

Ordinary JSON-serializable return values (dict, list, msgspec.Struct, primitives) are serialized into a single {"type": "text", "text": "..."} content block. To return binary payloads, resource links, or multi-block results with structuredContent, return MCPToolResult, MCPResourceLink, or MCPBlobResource:

docs/examples/snippets/tool_binary_content.py
"""Snippet: explicit binary and mixed MCP tool content blocks."""

from litestar import Litestar, get

from litestar_mcp import LitestarMCP, MCPBlobResource, MCPResourceLink, MCPToolResult


def build() -> "Litestar":
    # start-example
    @get("/reports/latest", mcp_tool=True)
    async def generate_report() -> "MCPToolResult":
        return MCPToolResult(
            content=[
                {"type": "text", "text": "Report generated."},
                MCPResourceLink(
                    name="report.pdf",
                    uri="litestar://latest_report",
                    mime_type="application/pdf",
                ),
            ],
            structured_content={"reportId": "latest"},
        )

    @get("/reports/inline", mcp_tool=True)
    async def download_report() -> "MCPBlobResource":
        payload = b"...pdf bytes..."
        return MCPBlobResource(
            uri="memory://reports/latest.pdf",
            data=payload,
            mime_type="application/pdf",
        )

    @get("/reports/resource", mcp_resource=True, media_type="application/pdf")
    async def latest_report() -> bytes:
        return b"...pdf bytes..."

    app = Litestar(route_handlers=[generate_report, download_report, latest_report], plugins=[LitestarMCP()])
    # end-example
    return app
  • MCPResourceLink: Emits a {"type": "resource_link", "name": ..., "uri": ..., "mimeType": ...} block instructing the client to fetch the payload on demand via resources/read.
  • MCPBlobResource: Base64-encodes data into an embedded {"type": "resource", "resource": {"uri": ..., "mimeType": ..., "blob": ...}} block, subject to MCPConfig.max_blob_bytes (default 25 MiB).
  • MCPToolResult: Combines multiple content blocks (content=[...]), optional structured_content={"...": ...}, and an explicit is_error flag.

Tool Error Contract (isError: true vs Protocol Errors)

Per the MCP specification (including SEP-2663), tool execution and argument validation failures are reported inside the tool result with isError: true so the LLM can inspect the failure and self-correct, whereas protocol-level routing failures return a JSON-RPC error object:

Failure Condition Wire Representation Status / Error Code
Missing or invalid tool argument (_validate_tool_arguments) result: {"content": [{"type": "text", "text": "{\"error\":\"Invalid tool arguments\",\"errors\":[{\"path\":\"/arguments/...\",\"message\":\"...\"}]}"}], "isError": true} HTTP 200 OK (isError: true with JSON Pointer paths)
Non-object params.arguments result: {"content": [{"type": "text", "text": "{\"error\":\"Tool arguments must be an object\"}"}], "isError": true} HTTP 200 OK (isError: true)
Handler raises HTTPException / Guard rejection / Exception result: {"content": [{"type": "text", "text": "..."}], "isError": true} HTTP 200 OK (isError: true)
Unknown or filtered tool name (or missing params.name) error: {"code": -32602, "message": "Tool not found: <name>"} HTTP 400 Bad Request (INVALID_PARAMS, -32602)
Missing required client capability (mcp_required_client_capabilities or task_support="required") error: {"code": -32021, "message": "...", "data": {"requiredCapabilities": {...}}} HTTP 400 Bad Request (MISSING_REQUIRED_CLIENT_CAPABILITY, -32021)