ADR-0016: Tool Interface Pattern¶
Status¶
Accepted
Date: 2025-01-11 (updated 2026-04-17 for StatusPro fork — examples replaced, core decision unchanged)
Context¶
MCP tools need consistent, type-safe interfaces for requests and responses. We needed to decide:
- How to structure tool parameters (flat vs nested)
- How to handle validation
- How to represent responses (structured vs string)
- How to integrate with FastMCP
- How to handle user confirmation for destructive operations
Decision¶
We adopt the Pydantic parameter annotations pattern combined with FastMCP
Elicitation for destructive operations. StatusPro's tools are small enough
that we use Pydantic Field() on each parameter directly rather than a
nested request model + Unpack() decorator; the Katana parent project used
the Unpack pattern for its larger request bodies and we kept the decorator
infrastructure (unpack.py) as an option.
Pattern components¶
1. Per-parameter annotations (typical StatusPro tool)¶
@mcp.tool(
name="update_order_status",
description="Change an order's status. Two-step confirm.",
)
async def update_order_status(
context: Context,
order_id: int,
status_code: Annotated[
str, Field(description="8-char status code, e.g. 'st000003'")
],
comment: Annotated[str | None, Field(description="Optional history comment")] = None,
public: Annotated[bool, Field(description="Visible to the customer")] = False,
email_customer: bool = True,
email_additional: bool = True,
confirm: Annotated[bool, Field(description="Must be true to apply the change")] = False,
) -> dict[str, Any]:
...
2. Request model + Unpack decorator (for complex bodies)¶
When a request has many fields or nested structure, wrap it in a Pydantic
model and use the @unpack_pydantic_params decorator (still available via
statuspro_mcp/unpack.py):
class BulkStatusUpdateRequest(BaseModel):
order_ids: list[int] = Field(..., min_length=1, max_length=50)
status_code: str
comment: str | None = None
public: bool = False
email_customer: bool = True
confirm: bool = False
@unpack_pydantic_params
async def bulk_update_order_status(
request: Annotated[BulkStatusUpdateRequest, Unpack()],
context: Context,
) -> dict[str, Any]:
...
3. Response shape¶
StatusPro tools return plain dicts. The mutation tools follow this shape:
{
"confirmed": bool,
"success": bool,
"status_code": int, # HTTP status from the API
# For bulk ops:
"note": "Bulk updates are queued and processed asynchronously.",
}
Non-mutation tools return typed Pydantic responses (e.g. list[OrderSummary],
list[StatusEntry]).
4. Elicitation pattern (safety-critical operations)¶
For destructive operations, we use FastMCP's elicitation to request user confirmation:
# Preview mode (confirm=false) — show what would happen
if not confirm:
return {"preview": preview, "confirmed": False}
# Request user confirmation via elicitation
result = await require_confirmation(
context,
f"Change order {order_id} status to {status_code}?",
)
if result is not ConfirmationResult.CONFIRMED:
return {"preview": preview, "confirmed": False, "result": result.value}
# User confirmed — proceed with the API call
response = await update_order_status_api.asyncio_detailed(...)
return {"confirmed": True, "success": is_success(response), ...}
5. Shared schemas¶
Common schemas live in statuspro_mcp/tools/schemas.py so every mutation
tool reuses the same confirmation flow:
# statuspro_mcp/tools/schemas.py
class ConfirmationSchema(BaseModel):
"""Schema for user confirmation elicitation."""
confirm: bool = Field(..., description="Confirm the action (true to proceed)")
async def require_confirmation(context: Context, message: str) -> ConfirmationResult:
...
Benefits¶
- Type safety: Pydantic validates all inputs at runtime
- Documentation: Field descriptions are self-documenting
- IDE support: Autocomplete and type checking work perfectly
- Testability: Easy to mock and test with Pydantic models
- Consistency: All mutation tools follow the same two-step confirm pattern
- Safety: Destructive operations require explicit user confirmation
- DRY: Shared
require_confirmationhelper across every mutation tool
Consequences¶
Positive¶
- Type-safe tool interfaces prevent runtime errors
- Self-documenting parameters improve developer experience
- Validation errors are clear and actionable
- Elicitation prevents accidental destructive operations
- Shared helpers ensure consistency across tools
Negative¶
- Per-parameter
Annotated[...]annotations are verbose for wide signatures - Unpack decorator adds complexity where it's used
- Elicitation adds an extra round-trip for confirmed operations
Neutral¶
- Elicitation pattern only used for destructive operations (4 of 9 tools)
- Preview-then-confirm means every mutation is at minimum a two-call flow
Alternatives considered¶
Alternative 1: Flat untyped parameters¶
async def update_order_status(
order_id: int,
status_code: str,
comment: str | None, # ❌ No Field description
...
context: Context,
) -> dict:
...
Why rejected: No validation, tool schemas lose field descriptions the model sees, harder to keep tools consistent.
Alternative 2: Dictionary-based¶
Why rejected: No IDE support, no validation, no documentation.
Alternative 3: Manual confirmation via response field (no elicitation)¶
async def update_order_status(...) -> dict:
if not confirmed:
return {"status": "pending", "confirmation_required": True}
# Otherwise apply
Why rejected: Two round trips, harder to use, no built-in UI integration for preview/confirm in Claude Desktop.
Implementation examples¶
Mutation tools using this pattern (all follow two-step confirm with elicitation):
update_order_statusadd_order_commentupdate_order_due_datebulk_update_order_status
Read-only tools (no elicitation):
list_orders,get_order,lookup_order,list_statuses,get_viable_statuses
References¶
- ADR-0011: Pydantic Domain Models
- ADR-0017: Automated Tool Documentation
- FastMCP Documentation — Elicitation pattern