Tools¶
Tools are executable operations — anything that takes arguments and
returns structured output. Tag a Litestar route handler with
mcp_tool="<tool_name>" and the plugin publishes it via tools/list
and tools/call. The task-manager demo registers five tool handlers
covering the full CRUD lifecycle:
docs/examples/task_manager/main.py - register_tools¶def register_tools(store: "dict[int, Task]") -> "list[Any]":
"""Return MCP tool handlers (task CRUD) bound to ``store``."""
# start-example
@get("/tasks", mcp_tool="list_tasks")
async def list_tasks(completed: "bool | None" = None) -> "list[Task]":
"""List all tasks, optionally filtered by completion status."""
if completed is None:
return list(store.values())
return [task for task in store.values() if task.completed == completed]
@get("/tasks/{task_id:int}", mcp_tool="get_task")
async def get_task(task_id: int) -> Task:
"""Get a specific task by ID."""
if task_id not in store:
raise NotFoundException(detail=f"Task {task_id} not found")
return store[task_id]
@post("/tasks", status_code=HTTP_201_CREATED, mcp_tool="create_task")
async def create_task(data: CreateTaskRequest) -> Task:
"""Create a new task."""
new_id = max(store.keys(), default=0) + 1
new_task = Task(id=new_id, title=data.title, description=data.description, completed=False)
store[new_id] = new_task
return new_task
@post("/tasks/{task_id:int}/complete", mcp_tool="complete_task")
async def complete_task(task_id: int) -> Task:
"""Mark a task as completed."""
if task_id not in store:
raise NotFoundException(detail=f"Task {task_id} not found")
store[task_id].completed = True
return store[task_id]
@delete("/tasks/{task_id:int}", mcp_tool="delete_task")
async def delete_task(task_id: int) -> None:
"""Delete a task by ID."""
if task_id not in store:
raise NotFoundException(detail=f"Task {task_id} not found")
del store[task_id]
# end-example
return [list_tasks, get_task, create_task, complete_task, delete_task]
The handlers themselves are ordinary Litestar @get / @post /
@delete callables — the only extra is the mcp_tool kwarg. Each
tool is discoverable via tools/list and invocable via
tools/call.
Tool arguments are validated against the handler's parsed_fn_signature
before dispatch — the same model Litestar uses for ordinary HTTP request
parsing. Missing required arguments surface as JSON-RPC
INVALID_PARAMS (-32602). Annotated[T, Parameter(...)] query
arguments are unwrapped and their Parameter constraints
(ge / le / min_length / pattern / …) flow through into
the advertised inputSchema.
JSON-RPC Round-Trip¶
After initialize, clients drive tools via tools/list and
tools/call:
# Initialise the server
curl -sS -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25","capabilities":{},
"clientInfo":{"name":"curl","version":"1.0"}}}'
# List every tool marked in the application
curl -sS -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# Execute a specific tool (task-manager demo)
curl -sS -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"list_tasks","arguments":{}}}'
Successful responses carry the handler's return value inside the standard JSON-RPC envelope.
Error Contract¶
Tool errors are reported differently from the other primitives. A handler
that raises or returns an error response is surfaced inside the tool
result with isError: true (and the detail in content), not as a
JSON-RPC error object — this lets the model see and react to the
failure. Only protocol-level problems (an unknown tool name, malformed
params) use a JSON-RPC error with INVALID_PARAMS (-32602).
See Prompts and Resources for how those primitives map the
handler's HTTP status onto JSON-RPC codes.