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:
- Argument Validation:
_validate_tool_argumentsvalidates incomingparams.argumentsagainst the handler'sparsed_fn_signatureandmsgspectype converters before dispatch. before_tool_callHook: If configured onMCPConfig,before_tool_callruns with(tool_name, arguments, request).- Guards: Route-, controller-, router-, and app-level Litestar
guardsexecute against the synthesizedRequest(preservingrequest.user,request.auth, headers, andlitestar-securitySecurityContext). - Dependency Injection: Litestar
Providedependencies, DishkaScope.REQUEST(FromDishka[T]) dependencies, and standaloneToolContext/Inject()/Depends(fn)bindings resolve cleanly. - Handler Execution &
after_tool_callHook: Sync and async handlers run, andafter_tool_callreceives theresult,exception, and elapsedduration.
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
propertiesininputSchema. Parameters without default values are listed inrequired. Annotated[T, Parameter(...)]Constraints: LitestarParametermetadata (title,description,gt,ge,lt,le,min_length,max_length,pattern,examples) and custommcp_headerextensions are unwrapped and emitted directly on the property schema.- Structured Body Models (
data): Handlers declaring adataparameter typed as amsgspec.Struct,@dataclass,TypedDict,pydantic.BaseModel, orattrsclass have their model fields expanded intoinputSchema. Callers can pass structured fields either nested under"data"or flattened at the top level ofarguments. - DI Exclusion: Parameters satisfied by Litestar
Providedependencies,FromDishka[T],ToolContext, or framework reservations (request,state,headers,scope,cookies) are automatically excluded frominputSchema.
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):
- Inferred HTTP verb defaults (when
MCPConfig.infer_http_method_annotations=True). @mcp_tool(annotations={...})dictionary.- Route
optmcp_annotations={...}dictionary. @mcp_tool(read_only=True | False)shorthand (setsannotations["readOnlyHint"]).- Route
optmcp_read_only=True | Falseshorthand (setsannotations["readOnlyHint"]). @mcp_tool(scopes=[...])(setsannotations["scopes"]if"scopes"was not already provided inannotations).
"""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:
"""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):
"""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
- On the initial
tools/call(wherecontext.input_responsesisNone), the handler executes synchronously and returnsMCPInputRequiredResult(resultType: "input_required") without allocating a task record. - When the client retries
tools/callsupplyingparams.inputResponsesandparams.requestState,MCPHandlerServicecreates the backgroundTaskRecordand dispatches execution with the supplied responses bound toget_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:
"""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 viaresources/read.MCPBlobResource: Base64-encodesdatainto an embedded{"type": "resource", "resource": {"uri": ..., "mimeType": ..., "blob": ...}}block, subject toMCPConfig.max_blob_bytes(default25 MiB).MCPToolResult: Combines multiple content blocks (content=[...]), optionalstructured_content={"...": ...}, and an explicitis_errorflag.
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) |