Skip to content

Commit 0b67bdd

Browse files
authored
fix(mcp): address Copilot review feedback from #42, #43, #44 (#45)
Retroactive follow-up addressing Copilot inline comments that arrived after PRs #42, #43, #44 were merged. Six findings across the three PRs: **From #42**: orders.py module docstring described `search` as matching "order number" only; per OpenAPI it partially matches order name, order number, customer name, or customer email. **From #43**: `_to_detail()` with `history_limit=0` silently returned the full list (because `items[-0:]` is `items[0:]`) while setting `history_truncated=True` — now raises `ValueError`. Added `_paginate_history()` helper with 9 unit tests for the pagination contract. Renamed misleading test `test_custom_history_limit_zero_is_not_supported` (which was actually testing limit=1). **From #44**: `StatusChangePreview.valid` description and `update_order_status` help row both implied no API call happens at preview validation; reworded to clarify the cached read to `viable-statuses` and that direct `confirm=True` calls bypass the UI guard. Going forward I'm waiting for Copilot's async review pass before merging.
1 parent e9c3f37 commit 0b67bdd

4 files changed

Lines changed: 133 additions & 16 deletions

File tree

statuspro_mcp_server/src/statuspro_mcp/resources/help.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
| `get_order` | `GET /orders/{id}` | Full detail for one order, with the most recent `history_limit` history entries (default 50). When `history_truncated` is true, use `get_order_history` for older entries. |
1616
| `get_order_history` | `GET /orders/{id}` (client-side paged) | Page through the full history timeline of one order. Use when `get_order` indicated truncation. |
1717
| `get_viable_statuses` | `GET /orders/{id}/viable-statuses` | Valid status transitions for the order's current state. |
18-
| `update_order_status` | `POST /orders/{id}/status` | Change status. Two-step confirm. Preview self-validates against viable transitions — invalid `status_code` is caught at preview time without the API round-trip. |
18+
| `update_order_status` | `POST /orders/{id}/status` | Change status. Two-step confirm. Preview self-validates against viable transitions (one extra read to `GET /orders/{id}/viable-statuses`, cached) — invalid `status_code` is surfaced before the write `POST` that would 422. |
1919
| `add_order_comment` | `POST /orders/{id}/comment` | Add a history comment. Two-step confirm. 5/min. |
2020
| `update_order_due_date` | `POST /orders/{id}/due-date` | Change due date / date range. Two-step confirm. |
2121
| `bulk_update_order_status` | `POST /orders/bulk-status` | Update up to 50 orders in one request. Two-step confirm. 5/min. |

statuspro_mcp_server/src/statuspro_mcp/tools/orders.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
The ``GET /orders/lookup`` endpoint is intentionally not exposed: it is the
1212
public, customer-verification path (requires ``number`` + ``email``) and adds
1313
nothing for an authenticated MCP caller, who can already use ``list_orders``
14-
(``search`` matches order number) or ``get_order`` (by id).
14+
(``search`` partially matches order name, order number, customer name, or
15+
customer email) or ``get_order`` (by id).
1516
"""
1617

1718
from __future__ import annotations
@@ -107,7 +108,15 @@ def _to_detail(
107108
Callers receive ``history_truncated`` + ``history_total_count`` to
108109
detect truncation and page through older entries via
109110
``get_order_history``.
111+
112+
``history_limit`` must be >= 1. The MCP tool layer enforces this via
113+
``Field(ge=1)``, but the helper guards explicitly: Python's
114+
``items[-0:]`` returns the full list (since ``-0 == 0``), which would
115+
silently contradict ``history_truncated=True`` when called with 0.
110116
"""
117+
if history_limit < 1:
118+
msg = f"history_limit must be >= 1, got {history_limit}"
119+
raise ValueError(msg)
111120
summary = _to_summary(order)
112121
history_items = getattr(order, "history", None) or []
113122
total = len(history_items)
@@ -122,6 +131,32 @@ def _to_detail(
122131
)
123132

124133

134+
def _paginate_history(
135+
items: list[Any], *, page: int, per_page: int
136+
) -> tuple[list[Any], int, int]:
137+
"""Slice the in-memory history array into one page.
138+
139+
Returns ``(page_items, total, total_pages)``. Extracted so the
140+
pagination contract is testable without spinning up FastMCP to
141+
invoke the registered ``get_order_history`` tool.
142+
143+
``page`` and ``per_page`` are assumed >= 1 (enforced at the tool
144+
layer via ``Field(ge=1)``); guard explicitly so a misuse raises
145+
rather than producing nonsensical slices.
146+
"""
147+
if page < 1:
148+
msg = f"page must be >= 1, got {page}"
149+
raise ValueError(msg)
150+
if per_page < 1:
151+
msg = f"per_page must be >= 1, got {per_page}"
152+
raise ValueError(msg)
153+
total = len(items)
154+
total_pages = max(1, (total + per_page - 1) // per_page)
155+
start = (page - 1) * per_page
156+
end = start + per_page
157+
return items[start:end], total, total_pages
158+
159+
125160
async def _status_color_catalog(services: Any) -> dict[str, str | None]:
126161
"""Fetch the status catalog once and return a ``{code: color}`` map.
127162
@@ -339,14 +374,13 @@ async def get_order_history(
339374
) -> OrderHistoryPage:
340375
services = get_services(context)
341376
# No server-side history pagination today — fetch the full order and
342-
# slice client-side. Cheap relative to LLM context cost.
377+
# slice client-side via _paginate_history. Cheap relative to LLM
378+
# context cost; the helper makes the slicing math testable.
343379
order = await services.client.orders.get(order_id)
344380
all_items = getattr(order, "history", None) or []
345-
total = len(all_items)
346-
total_pages = max(1, (total + per_page - 1) // per_page)
347-
start = (page - 1) * per_page
348-
end = start + per_page
349-
page_items = all_items[start:end]
381+
page_items, total, total_pages = _paginate_history(
382+
all_items, page=page, per_page=per_page
383+
)
350384
return OrderHistoryPage(
351385
order_id=order_id,
352386
page=page,

statuspro_mcp_server/src/statuspro_mcp/tools/schemas.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ class StatusChangePreview(BaseModel):
171171
default=True,
172172
description=(
173173
"True when new_status_code is a viable transition from the "
174-
"current state. False blocks confirmation."
174+
"current state (per GET /orders/{id}/viable-statuses). When "
175+
"False, the Prefab UI hides the Confirm button and surfaces "
176+
"viable_status_codes; the agent can still call the tool with "
177+
"confirm=True directly, in which case the API will likely 422."
175178
),
176179
)
177180
viable_status_codes: list[str] = Field(

statuspro_mcp_server/tests/tools/test_orders_history_truncation.py

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
Covers the contract from issue #40: get_order returns the most recent
44
`history_limit` entries (default 50) with `history_truncated` and
55
`history_total_count` flags so callers know to use get_order_history
6-
for older entries.
6+
for older entries. The pagination contract for get_order_history itself
7+
is exercised via the extracted `_paginate_history` helper.
78
"""
89

910
from __future__ import annotations
@@ -14,6 +15,7 @@
1415
from statuspro_mcp.tools.orders import (
1516
DEFAULT_HISTORY_LIMIT,
1617
_history_entry,
18+
_paginate_history,
1719
_to_detail,
1820
)
1921

@@ -93,17 +95,95 @@ def test_custom_history_limit_smaller(self):
9395
assert detail.history_truncated is True
9496
assert detail.history_total_count == 20
9597

96-
def test_custom_history_limit_zero_is_not_supported(self):
97-
"""history_limit must be >= 1 — guarded at the MCP tool layer.
98-
Internal _to_detail with limit=0 would slice to []; verify the slice
99-
math doesn't blow up (defensive).
98+
def test_custom_history_limit_one_returns_most_recent_entry(self):
99+
"""The MCP tool enforces history_limit >= 1; this verifies the
100+
smallest valid limit returns exactly the most recent entry.
100101
"""
101102
order = _make_order(history_count=5)
102-
# The tool itself rejects limit < 1 via Field(ge=1), but the helper
103-
# is permissive — verify it produces a sane result.
104103
detail = _to_detail(order, history_limit=1)
105104
assert len(detail.history) == 1
106105
assert detail.history_truncated is True
106+
assert detail.history_total_count == 5
107+
108+
def test_history_limit_zero_raises(self):
109+
"""The helper rejects history_limit < 1 explicitly. Without the
110+
guard, ``items[-0:]`` returns the FULL list (since ``-0 == 0``),
111+
which would silently contradict ``history_truncated=True``.
112+
"""
113+
order = _make_order(history_count=5)
114+
with pytest.raises(ValueError, match="history_limit must be >= 1"):
115+
_to_detail(order, history_limit=0)
116+
117+
def test_history_limit_negative_raises(self):
118+
order = _make_order(history_count=5)
119+
with pytest.raises(ValueError, match="history_limit must be >= 1"):
120+
_to_detail(order, history_limit=-1)
121+
122+
123+
@pytest.mark.unit
124+
class TestPaginateHistory:
125+
"""_paginate_history slices an in-memory list into page+total+total_pages."""
126+
127+
def test_empty_list_first_page(self):
128+
items, total, total_pages = _paginate_history([], page=1, per_page=10)
129+
assert items == []
130+
assert total == 0
131+
# Empty list still reports total_pages=1 so callers can iterate
132+
# ``for p in range(1, total_pages+1)`` without an off-by-one.
133+
assert total_pages == 1
134+
135+
def test_single_full_page(self):
136+
items, total, total_pages = _paginate_history(
137+
list(range(5)), page=1, per_page=10
138+
)
139+
assert items == [0, 1, 2, 3, 4]
140+
assert total == 5
141+
assert total_pages == 1
142+
143+
def test_multiple_pages_first(self):
144+
items, total, total_pages = _paginate_history(
145+
list(range(25)), page=1, per_page=10
146+
)
147+
assert items == list(range(10))
148+
assert total == 25
149+
assert total_pages == 3
150+
151+
def test_multiple_pages_middle(self):
152+
items, _, _ = _paginate_history(list(range(25)), page=2, per_page=10)
153+
assert items == list(range(10, 20))
154+
155+
def test_multiple_pages_last_partial(self):
156+
"""Final page may be shorter than per_page when total is not divisible."""
157+
items, total, total_pages = _paginate_history(
158+
list(range(25)), page=3, per_page=10
159+
)
160+
assert items == [20, 21, 22, 23, 24]
161+
assert total == 25
162+
assert total_pages == 3
163+
164+
def test_page_past_end_returns_empty(self):
165+
items, total, total_pages = _paginate_history(
166+
list(range(5)), page=99, per_page=10
167+
)
168+
assert items == []
169+
assert total == 5
170+
assert total_pages == 1
171+
172+
def test_per_page_larger_than_total(self):
173+
items, total, total_pages = _paginate_history(
174+
list(range(3)), page=1, per_page=100
175+
)
176+
assert items == [0, 1, 2]
177+
assert total == 3
178+
assert total_pages == 1
179+
180+
def test_invalid_page_raises(self):
181+
with pytest.raises(ValueError, match="page must be >= 1"):
182+
_paginate_history([1, 2, 3], page=0, per_page=10)
183+
184+
def test_invalid_per_page_raises(self):
185+
with pytest.raises(ValueError, match="per_page must be >= 1"):
186+
_paginate_history([1, 2, 3], page=1, per_page=0)
107187

108188

109189
@pytest.mark.unit

0 commit comments

Comments
 (0)