Skip to content

Commit 0368ce9

Browse files
authored
feat(mcp): add list_orders_in_workflow + document list_orders gotchas (#50)
Three follow-up tweaks identified after the F3 probe revealed how sparsely populated the workflow status field actually is in this tenant (84% of non-cancelled orders have no status set). ### New tool: list_orders_in_workflow Returns only orders that StatusPro is actively tracking through a workflow stage — i.e. orders with `status` assigned to one of the tenant's defined status codes. Internally fans out one `list_orders(status_code=…)` call per defined status (concurrency capped at 10) and merges the results, deduplicated by id. This was the operationally-useful filter that didn't exist: - `list_orders()` returned all 478 (includes cancelled) - `list_orders(exclude_cancelled=True)` returned 475 (84% have no status) - `list_orders(exclude_cancelled=True, status_code=X)` only one stage at a time `list_orders_in_workflow()` returns the 77 orders StatusPro is actually tracking — the right starting point for reconciliation flows. Live verified against the dev tenant: 77 unique orders across 7 active status codes (Ride Wrap Installation has 0 orders, correctly omitted). Optional `search` parameter applied within each per-status call. ### Doc tweaks on list_orders The tool description now calls out two gotchas surfaced during the F3 probes: 1. Many orders have NO status assigned. An agent calling list_orders and trying to inspect status.code in code will silently miss the 84% with no status. Description points to list_orders_in_workflow for the in-progress subset. 2. financial_status, fulfillment_status, and tags filter server-side but are NOT echoed back on results. The OrderListItem schema doesn't include them, so list-then-inspect produces wrong answers — agents should filter for what they need. Same gotchas captured in help.py's list_orders row. ### Files - statuspro_mcp_server/src/statuspro_mcp/tools/orders.py — new tool + description update + module docstring tool count (10 -> 11) - statuspro_mcp_server/src/statuspro_mcp/server.py — list_orders_in_workflow added to _READ_ONLY_TOOLS - statuspro_mcp_server/src/statuspro_mcp/resources/help.py — new row + gotcha note on existing list_orders row
1 parent 87af369 commit 0368ce9

4 files changed

Lines changed: 231 additions & 8 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
@@ -11,7 +11,8 @@
1111
1212
| Tool | Endpoint | Purpose |
1313
| ---- | -------- | ------- |
14-
| `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. |
14+
| `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. **Gotchas:** (1) many orders may have NO `status` set — use `list_orders_in_workflow` if you only want orders StatusPro is actively tracking; (2) `financial_status`, `fulfillment_status`, and `tags` filter server-side but are NOT echoed back on results — filter for what you need rather than list-then-inspect. |
15+
| `list_orders_in_workflow` | `GET /orders?status_code=…` xN (parallel, capped 10 concurrent) | Return only orders with a workflow status assigned. Iterates per defined status_code; merges results. Use when you want the operationally-tracked subset of orders, not the full list (which includes orders with no status set). |
1516
| `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. |
1617
| `get_order_history` | `GET /orders/{id}` (client-side paged) | Page through the full history timeline of one order. Use when `get_order` indicated truncation. |
1718
| `get_orders_batch` | `GET /orders/{id}` xN (parallel fan-out) | Fetch up to 50 orders by id in one tool call. Returns per-id found/not-found results. Useful when an external system hands you a list of ids. |

statuspro_mcp_server/src/statuspro_mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def _build_auth() -> "AuthProvider | None":
222222
# Add response caching middleware with TTLs for read-only tools
223223
_READ_ONLY_TOOLS = [
224224
"list_orders",
225+
"list_orders_in_workflow",
225226
"get_order",
226227
"get_order_history",
227228
"get_orders_batch",

statuspro_mcp_server/src/statuspro_mcp/tools/orders.py

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""MCP tools for StatusPro orders.
22
3-
10 tools covering the ``/orders*`` endpoints plus three batch read tools
4-
(``get_orders_batch``, ``lookup_orders_batch``, ``summarize_active_orders``)
5-
that fan out parallel calls under the hood when the StatusPro server has
6-
no native batch endpoint. Mutations use a two-step confirm pattern: call
7-
with ``confirm=False`` to see a preview, then ``confirm=True`` to execute
8-
(the client host elicits explicit user approval via ``ctx.elicit``).
3+
11 tools covering the ``/orders*`` endpoints plus a set of batch / aggregation
4+
read tools (``get_orders_batch``, ``lookup_orders_batch``,
5+
``list_orders_in_workflow``, ``summarize_active_orders``) that fan out
6+
parallel calls under the hood when the StatusPro server has no native batch
7+
endpoint. Mutations use a two-step confirm pattern: call with
8+
``confirm=False`` to see a preview, then ``confirm=True`` to execute (the
9+
client host elicits explicit user approval via ``ctx.elicit``).
910
1011
All read-and-mutation tools (``list_orders``, ``get_order``,
1112
``update_order_status``, ``add_order_comment``, ``update_order_due_date``,
@@ -23,6 +24,7 @@
2324
from __future__ import annotations
2425

2526
import asyncio
27+
import logging
2628
from typing import Annotated, Any
2729

2830
from fastmcp import Context, FastMCP
@@ -90,6 +92,8 @@
9092
)
9193
from statuspro_public_api_client.utils import is_success, unwrap
9294

95+
logger = logging.getLogger(__name__)
96+
9397

9498
def _to_summary(order: Any) -> OrderSummary:
9599
"""Convert a domain Order or attrs model to an OrderSummary."""
@@ -213,6 +217,27 @@ async def _count_fulfillment(
213217
return value.value, int(getattr(getattr(parsed, "meta", None), "total", None) or 0)
214218

215219

220+
def _merge_unique_by_id[T](batches: list[list[T]]) -> list[T]:
221+
"""Flatten ``batches`` preserving first-seen order, deduplicated by ``.id``.
222+
223+
Used by ``list_orders_in_workflow`` to merge per-status_code results.
224+
Output is deterministic: orders within a batch keep API order, and
225+
earlier batches take precedence over later ones for the same id.
226+
227+
Each item in each batch must have an ``.id`` attribute.
228+
"""
229+
seen_ids: set[Any] = set()
230+
out: list[T] = []
231+
for batch in batches:
232+
for item in batch:
233+
item_id = getattr(item, "id", None)
234+
if item_id is None or item_id in seen_ids:
235+
continue
236+
seen_ids.add(item_id)
237+
out.append(item)
238+
return out
239+
240+
216241
def _build_batch_response(
217242
requested_count: int, results: list[BatchOrderResult]
218243
) -> BatchOrderResponse:
@@ -324,7 +349,19 @@ def register_tools(mcp: FastMCP) -> None:
324349

325350
@mcp.tool(
326351
name="list_orders",
327-
description="List orders with optional filters. Auto-paginates when page is unset.",
352+
description=(
353+
"List orders with optional filters. Auto-paginates when page is "
354+
"unset.\n\n"
355+
"Two gotchas worth knowing about:\n"
356+
"1. Many orders may have NO `status` set at all (newly created, "
357+
"not yet moved into the workflow). If you only want orders "
358+
"StatusPro is actively tracking through workflow stages, use "
359+
"`list_orders_in_workflow` — it iterates per known status_code "
360+
"and returns only orders with a status assigned.\n"
361+
"2. The `financial_status`, `fulfillment_status`, and `tags` "
362+
"filters work server-side but those fields are NOT echoed back "
363+
"on results. Filter for what you need; don't list-then-inspect."
364+
),
328365
meta=UI_META,
329366
)
330367
async def list_orders(
@@ -423,6 +460,123 @@ async def list_orders(
423460
orders_table=orders_table,
424461
)
425462

463+
@mcp.tool(
464+
name="list_orders_in_workflow",
465+
description=(
466+
"Return only orders that StatusPro is actively tracking through "
467+
"a workflow stage — i.e. orders with a `status` set to one of "
468+
"the tenant's defined status codes. Excludes cancelled orders "
469+
"and orders with no status assigned (which can be a large "
470+
"fraction of the total — see `list_orders` description for "
471+
"context). Internally fans out one `list_orders(status_code=…)` "
472+
"call per defined status (concurrency capped at 10) and merges "
473+
"the results."
474+
),
475+
meta=UI_META,
476+
)
477+
async def list_orders_in_workflow(
478+
context: Context,
479+
search: Annotated[
480+
str | None,
481+
Field(
482+
description=(
483+
"Optional full-text search applied within each "
484+
"status_code call (matches order number, name, or "
485+
"customer fields)."
486+
),
487+
),
488+
] = None,
489+
) -> ToolResult:
490+
services = get_services(context)
491+
492+
# Fetch the status catalog first, then fetch the per-status order
493+
# lists concurrently via _bounded_gather (capped at 10 in-flight,
494+
# captures per-row exceptions). Each per-status call auto-paginates
495+
# (no `page` set), so we get the full set per status code without
496+
# ceiling at per_page=100.
497+
statuses_list = await services.client.statuses.list()
498+
active_codes: list[str] = []
499+
for s in statuses_list:
500+
code = getattr(s, "code", None)
501+
if isinstance(code, str) and code:
502+
active_codes.append(code)
503+
504+
async def fetch_for_code(code: str) -> list[Any]:
505+
kwargs: dict[str, Any] = {
506+
"exclude_cancelled": True,
507+
"status_code": code,
508+
"per_page": 100,
509+
}
510+
if search:
511+
kwargs["search"] = search
512+
return await services.client.orders.list(**kwargs)
513+
514+
# _bounded_gather catches Exception (not BaseException) so individual
515+
# failures (rate limit, transport error, auth) don't kill the whole
516+
# call — we proceed with whatever buckets succeeded. CancelledError
517+
# propagates correctly.
518+
gathered = await _bounded_gather(
519+
[fetch_for_code(code) for code in active_codes]
520+
)
521+
successful_batches: list[list[Any]] = []
522+
failed_codes: list[str] = []
523+
for code, result in zip(active_codes, gathered, strict=True):
524+
if isinstance(result, Exception):
525+
failed_codes.append(code)
526+
logger.warning(
527+
"list_orders_in_workflow: status_code=%s fetch failed: %s",
528+
code,
529+
_classify_error(result, what=f"status_code={code}"),
530+
)
531+
else:
532+
successful_batches.append(result)
533+
534+
orders = _merge_unique_by_id(successful_batches)
535+
536+
summaries = [_to_summary(o) for o in orders]
537+
summary_dicts = [s.model_dump() for s in summaries]
538+
filters_line = f"in workflow ({len(active_codes)} statuses)" + (
539+
f", search={search!r}" if search else ""
540+
)
541+
542+
response = OrderList(
543+
orders=summaries,
544+
total=len(summaries),
545+
filters={
546+
"in_workflow": True,
547+
"status_codes": [code for code, _, _ in active_codes if code],
548+
"search": search,
549+
},
550+
)
551+
app = build_orders_table_ui(
552+
summary_dicts,
553+
total=len(summaries),
554+
filters_line=filters_line,
555+
)
556+
orders_table = (
557+
format_md_table(
558+
headers=["Order #", "Customer", "Status", "Due"],
559+
rows=[
560+
[
561+
d.get("order_number") or "—",
562+
d.get("customer_name") or "—",
563+
d.get("status_name") or "—",
564+
d.get("due_date") or "—",
565+
]
566+
for d in summary_dicts
567+
],
568+
)
569+
or "_(no orders in workflow)_"
570+
)
571+
return make_tool_result(
572+
response,
573+
template_name="orders_list",
574+
ui=app,
575+
total=len(summaries),
576+
filters_line=f"Filters: {filters_line}",
577+
orders_table=orders_table,
578+
)
579+
426580
@mcp.tool(
427581
name="get_order",
428582
description=(

statuspro_mcp_server/tests/tools/test_batch_tools.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,73 @@ def test_mixed_outcomes(self):
251251
assert resp.error_count == 2 # ambiguous + ConnectionError
252252

253253

254+
@pytest.mark.unit
255+
class TestMergeUniqueById:
256+
"""`_merge_unique_by_id` is the dedupe helper used by `list_orders_in_workflow`.
257+
258+
It must produce deterministic output (so callers can rely on stable
259+
ordering across calls), preserve API order within batches, and skip
260+
items missing an id.
261+
"""
262+
263+
def test_empty_input(self):
264+
from statuspro_mcp.tools.orders import _merge_unique_by_id
265+
266+
assert _merge_unique_by_id([]) == []
267+
268+
def test_single_batch_passthrough(self):
269+
from statuspro_mcp.tools.orders import _merge_unique_by_id
270+
271+
a = OrderSummary(id=1)
272+
b = OrderSummary(id=2)
273+
result = _merge_unique_by_id([[a, b]])
274+
assert [o.id for o in result] == [1, 2]
275+
276+
def test_dedupe_across_batches(self):
277+
"""Order appearing in two status_code buckets is included exactly once."""
278+
from statuspro_mcp.tools.orders import _merge_unique_by_id
279+
280+
a = OrderSummary(id=1)
281+
b = OrderSummary(id=2)
282+
result = _merge_unique_by_id([[a, b], [a]])
283+
assert [o.id for o in result] == [1, 2]
284+
285+
def test_first_seen_wins(self):
286+
"""When the same id appears in two batches, the first batch's item wins.
287+
288+
Batches are passed in catalog order — so the order's "primary" status
289+
bucket (whichever comes first in the catalog) is what we surface.
290+
"""
291+
from statuspro_mcp.tools.orders import _merge_unique_by_id
292+
293+
a_v1 = OrderSummary(id=1, status_name="In Production")
294+
a_v2 = OrderSummary(id=1, status_name="Shipped")
295+
result = _merge_unique_by_id([[a_v1], [a_v2]])
296+
assert len(result) == 1
297+
assert result[0].status_name == "In Production"
298+
299+
def test_preserves_api_order_within_batch(self):
300+
from statuspro_mcp.tools.orders import _merge_unique_by_id
301+
302+
items = [OrderSummary(id=i) for i in (5, 2, 8, 1)]
303+
result = _merge_unique_by_id([items])
304+
assert [o.id for o in result] == [5, 2, 8, 1]
305+
306+
def test_skips_items_with_no_id(self):
307+
"""Defensive: if an item has no `.id`, skip it rather than crash."""
308+
from statuspro_mcp.tools.orders import _merge_unique_by_id
309+
310+
# OrderSummary requires id, so use a stand-in that has no id attr.
311+
class Anonymous:
312+
pass
313+
314+
a = OrderSummary(id=1)
315+
b = Anonymous()
316+
c = OrderSummary(id=3)
317+
result = _merge_unique_by_id([[a, b, c]])
318+
assert [getattr(o, "id", None) for o in result] == [1, 3]
319+
320+
254321
@pytest.mark.unit
255322
class TestExactMatchDisambiguationNoneSafety:
256323
"""Edge case: rows with None order_number/name must not match."""

0 commit comments

Comments
 (0)