Skip to content

Commit 650f5c7

Browse files
authored
feat: get_order history truncation + get_order_history tool (#43)
Two changes ship together: 1. **fix(client)**: Expose history entries on domain Order model. Pre-existing bug: domain `Order` Pydantic model only declared `history_count`, not the `history` array — pydantic validation silently dropped server-returned history. The MCP `get_order` tool's `history` field has been always empty in practice today. 2. **feat(mcp)**: Truncate `get_order` history (default 50 most-recent) with `history_truncated` and `history_total_count` flags. Adds new `get_order_history(order_id, page, per_page)` tool for paged access to older entries. Help resource updated. Closes #40.
1 parent dbd06fe commit 650f5c7

8 files changed

Lines changed: 406 additions & 25 deletions

File tree

statuspro_mcp_server/src/statuspro_mcp/resources/help.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
| Tool | Endpoint | Purpose |
1313
| ---- | -------- | ------- |
1414
| `list_orders` | `GET /orders` | Paginated list with filters (search, status, tags, due-date range). Auto-paginates. `search` matches order number, name, or customer fields — use it to find an order from just an order number. |
15-
| `get_order` | `GET /orders/{id}` | Full detail for one order, including history. |
15+
| `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. |
16+
| `get_order_history` | `GET /orders/{id}` (client-side paged) | Page through the full history timeline of one order. Use when `get_order` indicated truncation. |
1617
| `get_viable_statuses` | `GET /orders/{id}/viable-statuses` | Valid status transitions for the order's current state. |
1718
| `update_order_status` | `POST /orders/{id}/status` | Change status. Two-step confirm. |
1819
| `add_order_comment` | `POST /orders/{id}/comment` | Add a history comment. Two-step confirm. 5/min. |

statuspro_mcp_server/src/statuspro_mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ def _build_auth() -> "AuthProvider | None":
223223
_READ_ONLY_TOOLS = [
224224
"list_orders",
225225
"get_order",
226+
"get_order_history",
226227
"list_statuses",
227228
"get_viable_statuses",
228229
]

statuspro_mcp_server/src/statuspro_mcp/tools/orders.py

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""MCP tools for StatusPro orders.
22
3-
6 tools mapping to the ``/orders*`` endpoints. Mutations use a two-step confirm
3+
7 tools mapping to the ``/orders*`` endpoints. Mutations use a two-step confirm
44
pattern: call with ``confirm=False`` to see a preview, then ``confirm=True`` to
55
execute (the client host elicits explicit user approval via ``ctx.elicit``).
66
@@ -33,6 +33,7 @@
3333
ConfirmationResult,
3434
HistoryEntry,
3535
OrderDetail,
36+
OrderHistoryPage,
3637
OrderList,
3738
OrderSummary,
3839
StatusChangePreview,
@@ -93,14 +94,31 @@ def _history_entry(item: Any) -> HistoryEntry:
9394
)
9495

9596

96-
def _to_detail(order: Any) -> OrderDetail:
97-
"""Convert a domain Order into a full ``OrderDetail`` including history."""
97+
DEFAULT_HISTORY_LIMIT = 50
98+
99+
100+
def _to_detail(
101+
order: Any, *, history_limit: int = DEFAULT_HISTORY_LIMIT
102+
) -> OrderDetail:
103+
"""Convert a domain Order into a full ``OrderDetail``.
104+
105+
History is truncated to the most recent ``history_limit`` entries
106+
(server returns chronological order, so we slice from the tail).
107+
Callers receive ``history_truncated`` + ``history_total_count`` to
108+
detect truncation and page through older entries via
109+
``get_order_history``.
110+
"""
98111
summary = _to_summary(order)
99112
history_items = getattr(order, "history", None) or []
113+
total = len(history_items)
114+
truncated = total > history_limit
115+
visible = history_items[-history_limit:] if truncated else history_items
100116
return OrderDetail(
101117
**summary.model_dump(),
102118
due_date_to=iso_or_none(getattr(order, "due_date_to", None)),
103-
history=[_history_entry(h) for h in history_items],
119+
history=[_history_entry(h) for h in visible],
120+
history_truncated=truncated,
121+
history_total_count=total,
104122
)
105123

106124

@@ -228,39 +246,60 @@ async def list_orders(
228246

229247
@mcp.tool(
230248
name="get_order",
231-
description="Fetch full details for one order by id (with history).",
249+
description=(
250+
"Fetch full details for one order by id (with history). "
251+
"History is capped at history_limit entries (default 50, most recent); "
252+
"if history_truncated is true on the result, use get_order_history to "
253+
"page through older entries."
254+
),
232255
meta=UI_META,
233256
)
234257
async def get_order(
235258
context: Context,
236259
order_id: Annotated[int, Field(description="StatusPro order id")],
260+
history_limit: Annotated[
261+
int,
262+
Field(
263+
description="Max history entries to include (most recent N).",
264+
ge=1,
265+
le=500,
266+
),
267+
] = DEFAULT_HISTORY_LIMIT,
237268
) -> ToolResult:
238269
services = get_services(context)
239270
order, catalog = await asyncio.gather(
240271
services.client.orders.get(order_id),
241272
_status_color_catalog(services),
242273
)
243274

244-
detail = _to_detail(order)
275+
detail = _to_detail(order, history_limit=history_limit)
245276
status_color = catalog.get(detail.status_code) if detail.status_code else None
246277

247278
app = build_order_detail_ui(detail.model_dump(), status_color=status_color)
248279

249-
history_table = (
250-
format_md_table(
251-
headers=["When", "Event", "Status", "Comment"],
252-
rows=[
253-
[
254-
h.created_at or "—",
255-
h.event or "—",
256-
h.status_name or "—",
257-
h.comment or "—",
258-
]
259-
for h in detail.history
260-
],
261-
)
262-
or "_(no history)_"
280+
history_rows = format_md_table(
281+
headers=["When", "Event", "Status", "Comment"],
282+
rows=[
283+
[
284+
h.created_at or "—",
285+
h.event or "—",
286+
h.status_name or "—",
287+
h.comment or "—",
288+
]
289+
for h in detail.history
290+
],
263291
)
292+
if not history_rows:
293+
history_table = "_(no history)_"
294+
elif detail.history_truncated:
295+
history_table = (
296+
f"_Showing {len(detail.history)} most recent of "
297+
f"{detail.history_total_count} entries — use "
298+
f"`get_order_history(order_id={detail.id})` for older entries._\n\n"
299+
+ history_rows
300+
)
301+
else:
302+
history_table = history_rows
264303
due_date_range = f" — {detail.due_date_to}" if detail.due_date_to else ""
265304

266305
return make_tool_result(
@@ -278,6 +317,45 @@ async def get_order(
278317
history_table=history_table,
279318
)
280319

320+
@mcp.tool(
321+
name="get_order_history",
322+
description=(
323+
"Page through an order's full history timeline. Useful when "
324+
"get_order returned history_truncated=true and you need older "
325+
"entries. Page is 1-based; per_page defaults to 50, max 100."
326+
),
327+
)
328+
async def get_order_history(
329+
context: Context,
330+
order_id: Annotated[int, Field(description="StatusPro order id")],
331+
page: Annotated[
332+
int,
333+
Field(description="1-based page number.", ge=1),
334+
] = 1,
335+
per_page: Annotated[
336+
int,
337+
Field(description="Entries per page (max 100).", ge=1, le=100),
338+
] = 50,
339+
) -> OrderHistoryPage:
340+
services = get_services(context)
341+
# No server-side history pagination today — fetch the full order and
342+
# slice client-side. Cheap relative to LLM context cost.
343+
order = await services.client.orders.get(order_id)
344+
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]
350+
return OrderHistoryPage(
351+
order_id=order_id,
352+
page=page,
353+
per_page=per_page,
354+
total=total,
355+
total_pages=total_pages,
356+
entries=[_history_entry(h) for h in page_items],
357+
)
358+
281359
@mcp.tool(
282360
name="update_order_status",
283361
description=(

statuspro_mcp_server/src/statuspro_mcp/tools/schemas.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,38 @@ class HistoryEntry(BaseModel):
8686

8787

8888
class OrderDetail(OrderSummary):
89-
"""Full order record including the history timeline.
89+
"""Full order record including the (possibly truncated) history timeline.
9090
9191
Returned by ``get_order`` — extends ``OrderSummary`` with the fields that
92-
aren't useful in list views.
92+
aren't useful in list views. The ``history`` array is truncated to the
93+
most recent ``history_limit`` entries when the order has many; callers
94+
should check ``history_truncated`` and use ``get_order_history`` to page
95+
through older entries when set.
9396
"""
9497

9598
due_date_to: str | None = None
9699
history: list[HistoryEntry] = Field(default_factory=list)
100+
history_truncated: bool = Field(
101+
default=False,
102+
description="True when older history entries were omitted; "
103+
"use get_order_history to page through them.",
104+
)
105+
history_total_count: int = Field(
106+
default=0,
107+
description="Total number of history entries on the order; "
108+
"len(history) <= history_total_count.",
109+
)
110+
111+
112+
class OrderHistoryPage(BaseModel):
113+
"""One page of history entries returned by ``get_order_history``."""
114+
115+
order_id: int
116+
page: int
117+
per_page: int
118+
total: int
119+
total_pages: int
120+
entries: list[HistoryEntry]
97121

98122

99123
class OrderList(BaseModel):
@@ -165,6 +189,7 @@ class StatusChangeResult(BaseModel):
165189
"ConfirmationSchema",
166190
"HistoryEntry",
167191
"OrderDetail",
192+
"OrderHistoryPage",
168193
"OrderList",
169194
"OrderSummary",
170195
"StatusChangePreview",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Tests for history truncation in _to_detail and the get_order_history tool.
2+
3+
Covers the contract from issue #40: get_order returns the most recent
4+
`history_limit` entries (default 50) with `history_truncated` and
5+
`history_total_count` flags so callers know to use get_order_history
6+
for older entries.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import UTC, datetime
12+
13+
import pytest
14+
from statuspro_mcp.tools.orders import (
15+
DEFAULT_HISTORY_LIMIT,
16+
_history_entry,
17+
_to_detail,
18+
)
19+
20+
from statuspro_public_api_client.domain import HistoryEntry, Order, OrderStatus
21+
22+
23+
def _make_order(*, history_count: int) -> Order:
24+
"""Build a domain Order with `history_count` history entries.
25+
26+
Entries are ordered chronologically (oldest first) — server convention.
27+
"""
28+
history = [
29+
HistoryEntry(
30+
event="status_change",
31+
status=OrderStatus(code="st000002", name="In Production"),
32+
comment=None,
33+
comment_is_public=False,
34+
created_at=datetime(2026, 1, 1, 10, 0, i % 60, tzinfo=UTC),
35+
)
36+
for i in range(history_count)
37+
]
38+
return Order(
39+
id=42,
40+
name="#42",
41+
order_number="42",
42+
history=history,
43+
)
44+
45+
46+
@pytest.mark.unit
47+
class TestToDetailTruncation:
48+
"""_to_detail respects history_limit and reports truncation accurately."""
49+
50+
def test_no_history_returns_empty_not_truncated(self):
51+
order = Order(id=1, name="#1")
52+
detail = _to_detail(order)
53+
assert detail.history == []
54+
assert detail.history_truncated is False
55+
assert detail.history_total_count == 0
56+
57+
def test_history_below_limit_passes_through(self):
58+
order = _make_order(history_count=10)
59+
detail = _to_detail(order, history_limit=DEFAULT_HISTORY_LIMIT)
60+
assert len(detail.history) == 10
61+
assert detail.history_truncated is False
62+
assert detail.history_total_count == 10
63+
64+
def test_history_at_limit_not_truncated(self):
65+
"""Exactly N entries with limit=N is not truncation."""
66+
order = _make_order(history_count=DEFAULT_HISTORY_LIMIT)
67+
detail = _to_detail(order)
68+
assert len(detail.history) == DEFAULT_HISTORY_LIMIT
69+
assert detail.history_truncated is False
70+
assert detail.history_total_count == DEFAULT_HISTORY_LIMIT
71+
72+
def test_history_above_limit_truncated_to_most_recent(self):
73+
"""Server returns chronological (oldest first); we keep the tail."""
74+
order = _make_order(history_count=DEFAULT_HISTORY_LIMIT + 7)
75+
detail = _to_detail(order)
76+
assert len(detail.history) == DEFAULT_HISTORY_LIMIT
77+
assert detail.history_truncated is True
78+
assert detail.history_total_count == DEFAULT_HISTORY_LIMIT + 7
79+
80+
# The kept entries are the LAST N (most recent), not the first N.
81+
# First kept entry is index 7 (entries 0..6 were trimmed). created_at
82+
# on the MCP-shaped HistoryEntry is the ISO string, so parse to check
83+
# the second.
84+
first_kept = detail.history[0].created_at
85+
assert first_kept is not None
86+
first_kept_dt = datetime.fromisoformat(first_kept)
87+
assert first_kept_dt.second == 7 % 60
88+
89+
def test_custom_history_limit_smaller(self):
90+
order = _make_order(history_count=20)
91+
detail = _to_detail(order, history_limit=5)
92+
assert len(detail.history) == 5
93+
assert detail.history_truncated is True
94+
assert detail.history_total_count == 20
95+
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).
100+
"""
101+
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.
104+
detail = _to_detail(order, history_limit=1)
105+
assert len(detail.history) == 1
106+
assert detail.history_truncated is True
107+
108+
109+
@pytest.mark.unit
110+
class TestHistoryEntryConversion:
111+
"""_history_entry converts a domain HistoryEntry into the MCP shape."""
112+
113+
def test_status_change_entry(self):
114+
domain_entry = HistoryEntry(
115+
event="status_change",
116+
status=OrderStatus(code="st000002", name="In Production"),
117+
comment=None,
118+
comment_is_public=False,
119+
created_at=datetime(2026, 3, 12, 10, 14, tzinfo=UTC),
120+
)
121+
mcp_entry = _history_entry(domain_entry)
122+
assert mcp_entry.event == "status_change"
123+
assert mcp_entry.status_code == "st000002"
124+
assert mcp_entry.status_name == "In Production"
125+
assert mcp_entry.comment is None
126+
assert mcp_entry.created_at == "2026-03-12T10:14:00+00:00"
127+
128+
def test_comment_entry(self):
129+
domain_entry = HistoryEntry(
130+
event="comment_added",
131+
status=None,
132+
comment="Customer asked about ETA.",
133+
comment_is_public=False,
134+
created_at=datetime(2026, 3, 13, 9, 0, tzinfo=UTC),
135+
)
136+
mcp_entry = _history_entry(domain_entry)
137+
assert mcp_entry.event == "comment_added"
138+
assert mcp_entry.status_code is None
139+
assert mcp_entry.status_name is None
140+
assert mcp_entry.comment == "Customer asked about ETA."
141+
assert mcp_entry.comment_is_public is False

statuspro_public_api_client/domain/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818

1919
from .base import StatusProBaseModel
2020
from .converters import to_unset, unwrap_unset
21-
from .order import Customer, Order, OrderStatus, PageMeta
21+
from .order import Customer, HistoryEntry, Order, OrderStatus, PageMeta
2222
from .status import Status
2323

2424
__all__ = [
2525
"Customer",
26+
"HistoryEntry",
2627
"Order",
2728
"OrderStatus",
2829
"PageMeta",

0 commit comments

Comments
 (0)