Accepted
Date: 2025-01-11 (updated 2026-04-17 for StatusPro fork — examples replaced, core decision unchanged)
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
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.
@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]:
...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]:
...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]).
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), ...}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:
...- 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
- 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
- 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
- Elicitation pattern only used for destructive operations (4 of 9 tools)
- Preview-then-confirm means every mutation is at minimum a two-call flow
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.
async def update_order_status(
params: dict, # ❌ No type safety
context: Context,
) -> dict:
...Why rejected: No IDE support, no validation, no documentation.
async def update_order_status(...) -> dict:
if not confirmed:
return {"status": "pending", "confirmation_required": True}
# Otherwise applyWhy rejected: Two round trips, harder to use, no built-in UI integration for preview/confirm in Claude Desktop.
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
- ADR-0011: Pydantic Domain Models
- ADR-0017: Automated Tool Documentation
- FastMCP Documentation — Elicitation pattern