44
55Accepted
66
7- Date: 2025-01-11
7+ Date: 2025-01-11 (updated 2026-04-17 for StatusPro fork — examples replaced,
8+ core decision unchanged)
89
910## Context
1011
11- MCP tools need consistent, type-safe interfaces for requests and responses. We needed to
12- decide:
12+ MCP tools need consistent, type-safe interfaces for requests and responses.
13+ We needed to decide:
1314
1415- How to structure tool parameters (flat vs nested)
1516- How to handle validation
@@ -19,105 +20,125 @@ decide:
1920
2021## Decision
2122
22- We adopt the ** Unpack Pattern with Pydantic Models** combined with ** FastMCP
23- Elicitation** for destructive operations.
23+ We adopt the ** Pydantic parameter annotations** pattern combined with ** FastMCP
24+ Elicitation** for destructive operations. StatusPro's tools are small enough
25+ that we use Pydantic ` Field() ` on each parameter directly rather than a
26+ nested request model + ` Unpack() ` decorator; the Katana parent project used
27+ the Unpack pattern for its larger request bodies and we kept the decorator
28+ infrastructure (` unpack.py ` ) as an option.
2429
25- ### Pattern Components
30+ ### Pattern components
2631
27- #### 1. Request Models
28-
29- Pydantic models define tool parameters with full type safety and validation:
32+ #### 1. Per-parameter annotations (typical StatusPro tool)
3033
3134``` python
32- class CreatePurchaseOrderRequest (BaseModel ):
33- """ Request to create a purchase order."""
34- supplier_id: int = Field(... , description = " Supplier ID" )
35- location_id: int = Field(... , description = " Location ID where items will be received" )
36- order_number: str = Field(... , description = " Purchase order number" )
37- items: list[PurchaseOrderItem] = Field(... , description = " Line items" , min_length = 1 )
38- confirm: bool = Field(False , description = " If false, returns preview. If true, creates order." )
35+ @mcp.tool (
36+ name = " update_order_status" ,
37+ description = " Change an order's status. Two-step confirm." ,
38+ )
39+ async def update_order_status (
40+ context : Context,
41+ order_id : int ,
42+ status_code : Annotated[
43+ str , Field(description = " 8-char status code, e.g. 'st000003'" )
44+ ],
45+ comment : Annotated[str | None , Field(description = " Optional history comment" )] = None ,
46+ public : Annotated[bool , Field(description = " Visible to the customer" )] = False ,
47+ email_customer : bool = True ,
48+ email_additional : bool = True ,
49+ confirm : Annotated[bool , Field(description = " Must be true to apply the change" )] = False ,
50+ ) -> dict[str , Any]:
51+ ...
3952```
4053
41- #### 2. Unpack Decorator
54+ #### 2. Request model + Unpack decorator (for complex bodies)
4255
43- Flattens nested models for FastMCP compatibility:
56+ When a request has many fields or nested structure, wrap it in a Pydantic
57+ model and use the ` @unpack_pydantic_params ` decorator (still available via
58+ ` statuspro_mcp/unpack.py ` ):
4459
4560``` python
46- @observe_tool
61+ class BulkStatusUpdateRequest (BaseModel ):
62+ order_ids: list[int ] = Field(... , min_length = 1 , max_length = 50 )
63+ status_code: str
64+ comment: str | None = None
65+ public: bool = False
66+ email_customer: bool = True
67+ confirm: bool = False
68+
4769@unpack_pydantic_params
48- async def create_purchase_order (
49- request : Annotated[CreatePurchaseOrderRequest, Unpack()],
50- context : Context
51- ) -> PurchaseOrderResponse:
52- """ Create a new purchase order with user confirmation."""
70+ async def bulk_update_order_status (
71+ request : Annotated[BulkStatusUpdateRequest, Unpack()],
72+ context : Context,
73+ ) -> dict[str , Any]:
5374 ...
5475```
5576
56- #### 3. Response Models
77+ #### 3. Response shape
5778
58- Structured responses with success/failure states :
79+ StatusPro tools return plain dicts. The mutation tools follow this shape :
5980
6081``` python
61- class PurchaseOrderResponse (BaseModel ):
62- """ Response from creating a purchase order."""
63- id : int | None = None
64- order_number: str
65- supplier_id: int
66- status: str
67- total_cost: float | None = None
68- is_preview: bool
69- message: str
70- warnings: list[str ] = []
71- next_actions: list[str ] = []
82+ {
83+ " confirmed" : bool ,
84+ " success" : bool ,
85+ " status_code" : int , # HTTP status from the API
86+ # For bulk ops:
87+ " note" : " Bulk updates are queued and processed asynchronously." ,
88+ }
7289```
7390
74- #### 4. Elicitation Pattern (Safety-Critical Operations)
91+ Non-mutation tools return typed Pydantic responses (e.g. ` list[OrderSummary] ` ,
92+ ` list[StatusEntry] ` ).
7593
76- For destructive operations, we use FastMCP's elicitation to request user confirmation:
94+ #### 4. Elicitation pattern (safety-critical operations)
95+
96+ For destructive operations, we use FastMCP's elicitation to request user
97+ confirmation:
7798
7899``` python
79- # Preview mode (confirm=false) - show what would happen
80- if not request. confirm:
81- return preview_response()
100+ # Preview mode (confirm=false) — show what would happen
101+ if not confirm:
102+ return { " preview " : preview, " confirmed " : False }
82103
83104# Request user confirmation via elicitation
84- elicit_result = await context.elicit (
85- f " Create purchase order { order_number } with { item_count } items totaling $ { total } ? " ,
86- ConfirmationSchema ,
105+ result = await require_confirmation (
106+ context ,
107+ f " Change order { order_id } status to { status_code } ? " ,
87108)
109+ if result is not ConfirmationResult.CONFIRMED :
110+ return {" preview" : preview, " confirmed" : False , " result" : result.value}
88111
89- # Handle user response
90- if elicit_result.action != " accept" :
91- return cancelled_response()
92-
93- if not elicit_result.data.confirm:
94- return declined_response()
95-
96- # User confirmed - proceed with operation
97- result = await create_order()
98- return success_response(result)
112+ # User confirmed — proceed with the API call
113+ response = await update_order_status_api.asyncio_detailed(... )
114+ return {" confirmed" : True , " success" : is_success(response), ... }
99115```
100116
101- #### 5. Shared Schemas
117+ #### 5. Shared schemas
102118
103- Common schemas are extracted to ` statuspro_mcp/tools/schemas.py ` to avoid duplication:
119+ Common schemas live in ` statuspro_mcp/tools/schemas.py ` so every mutation
120+ tool reuses the same confirmation flow:
104121
105122``` python
106123# statuspro_mcp/tools/schemas.py
107124class ConfirmationSchema (BaseModel ):
108125 """ Schema for user confirmation elicitation."""
109- confirm: bool = Field(... , description = " True to proceed, False to cancel" )
126+ confirm: bool = Field(... , description = " Confirm the action (true to proceed)" )
127+
128+
129+ async def require_confirmation (context : Context, message : str ) -> ConfirmationResult:
130+ ...
110131```
111132
112133### Benefits
113134
114- - ** Type Safety ** : Pydantic validates all inputs at runtime
115- - ** Documentation** : Model fields are self-documenting with descriptions
116- - ** IDE Support ** : Autocomplete and type checking work perfectly
135+ - ** Type safety ** : Pydantic validates all inputs at runtime
136+ - ** Documentation** : Field descriptions are self-documenting
137+ - ** IDE support ** : Autocomplete and type checking work perfectly
117138- ** Testability** : Easy to mock and test with Pydantic models
118- - ** Consistency** : All tools follow the same pattern
139+ - ** Consistency** : All mutation tools follow the same two-step confirm pattern
119140- ** Safety** : Destructive operations require explicit user confirmation
120- - ** DRY** : Shared schemas eliminate duplication
141+ - ** DRY** : Shared ` require_confirmation ` helper across every mutation tool
121142
122143## Consequences
123144
@@ -126,83 +147,77 @@ class ConfirmationSchema(BaseModel):
126147- Type-safe tool interfaces prevent runtime errors
127148- Self-documenting parameters improve developer experience
128149- Validation errors are clear and actionable
129- - Easy to add new parameters (just update model)
130150- Elicitation prevents accidental destructive operations
131- - Shared schemas ensure consistency across tools
151+ - Shared helpers ensure consistency across tools
132152
133153### Negative
134154
135- - More boilerplate (request/response models for each tool)
136- - Unpack decorator adds complexity
137- - Learning curve for new contributors
138- - Elicitation adds extra step for confirmed operations
155+ - Per-parameter ` Annotated[...] ` annotations are verbose for wide signatures
156+ - Unpack decorator adds complexity where it's used
157+ - Elicitation adds an extra round-trip for confirmed operations
139158
140159### Neutral
141160
142- - Models live in same file as tool implementation
143- - Each tool has 2-3 model classes (Request, Response, nested types)
144- - Elicitation pattern only used for destructive operations
161+ - Elicitation pattern only used for destructive operations (4 of 9 tools)
162+ - Preview-then-confirm means every mutation is at minimum a two-call flow
145163
146- ## Alternatives Considered
164+ ## Alternatives considered
147165
148- ### Alternative 1: Flat Parameters
166+ ### Alternative 1: Flat untyped parameters
149167
150168``` python
151- async def create_purchase_order (
152- supplier_id : int ,
153- location_id : int ,
154- order_number : str ,
155- items : list[ dict ], # ❌ Not type-safe
156- context : Context
169+ async def update_order_status (
170+ order_id : int ,
171+ status_code : str ,
172+ comment : str | None , # ❌ No Field description
173+ ...
174+ context : Context,
157175) -> dict :
158176 ...
159177```
160178
161- ** Why rejected** : No validation, not type-safe, hard to document nested structures
179+ ** Why rejected** : No validation, tool schemas lose field descriptions the
180+ model sees, harder to keep tools consistent.
162181
163- ### Alternative 2: Dictionary-Based
182+ ### Alternative 2: Dictionary-based
164183
165184``` python
166- async def create_purchase_order (
167- params : dict , # ❌ No type safety
168- context : Context
185+ async def update_order_status (
186+ params : dict , # ❌ No type safety
187+ context : Context,
169188) -> dict :
170189 ...
171190```
172191
173- ** Why rejected** : No IDE support, no validation, no documentation
192+ ** Why rejected** : No IDE support, no validation, no documentation.
174193
175- ### Alternative 3: Manual Confirmation via Response Field
194+ ### Alternative 3: Manual confirmation via response field (no elicitation)
176195
177196``` python
178- # Return a "pending" response, require second call to confirm
179- async def create_purchase_order (...) -> dict :
197+ async def update_order_status (...) -> dict :
180198 if not confirmed:
181199 return {" status" : " pending" , " confirmation_required" : True }
182- # Otherwise create
200+ # Otherwise apply
183201```
184202
185- ** Why rejected** : Two API calls required, harder to use, no built-in UI support
203+ ** Why rejected** : Two round trips, harder to use, no built-in UI integration
204+ for preview/confirm in Claude Desktop.
205+
206+ ## Implementation examples
207+
208+ Mutation tools using this pattern (all follow two-step confirm with elicitation):
186209
187- ## Implementation Examples
210+ - ` update_order_status `
211+ - ` add_order_comment `
212+ - ` update_order_due_date `
213+ - ` bulk_update_order_status `
188214
189- Tools using this pattern :
215+ Read-only tools (no elicitation) :
190216
191- - ` create_purchase_order ` - Preview/confirm with elicitation
192- - ` receive_purchase_order ` - Preview/confirm with elicitation
193- - ` create_manufacturing_order ` - Preview/confirm with elicitation
194- - ` fulfill_order ` - Preview/confirm with elicitation
195- - ` verify_order_document ` - Read-only, no elicitation needed
196- - ` search_items ` - Read-only, no elicitation needed
217+ - ` list_orders ` , ` get_order ` , ` lookup_order ` , ` list_statuses ` , ` get_viable_statuses `
197218
198219## References
199220
200221- [ ADR-0011: Pydantic Domain Models] ( ../../statuspro_public_api_client/docs/adr/0011-pydantic-domain-models.md )
201222- [ ADR-0017: Automated Tool Documentation] ( 0017-automated-tool-documentation.md )
202- - [ statuspro_mcp/unpack.py] ( ../../src/statuspro_mcp/unpack.py ) - Unpack decorator
203- implementation
204- - [ statuspro_mcp/tools/schemas.py] ( ../../src/statuspro_mcp/tools/schemas.py ) - Shared
205- confirmation schema
206- - [ FastMCP Documentation] ( https://github.com/jlowin/fastmcp ) - Elicitation pattern
207- - [ PR #173 ] ( https://github.com/dougborg/statuspro-openapi-client/pull/173 ) - Elicitation
208- implementation
223+ - [ FastMCP Documentation] ( https://github.com/jlowin/fastmcp ) — Elicitation pattern
0 commit comments