Skip to content

Commit 6cfdd98

Browse files
dougborgclaude
andauthored
fix: surface upstream body on 5xx and unbreak review_urgent_order_requirements 415 (#203)
Two fixes driven by the 2026-05-26 MCP bug report. 1. utils.unwrap() now appends a truncated body excerpt to the error message when the response has no parsed body and a non-2xx status. StockTrim's Order Plan endpoint sometimes returns 500 with an HTML stack trace or 415 with a plaintext reason; the previous "API error with status N" message was opaque without those bodies. Excerpts are bounded at 500 chars with a "…[+N chars]" suffix so log lines stay readable, and UTF-8 decode errors fall back to a byte-count placeholder. 2. review_urgent_order_requirements built an OrderPlanFilterCriteriaDto (list-shaped, used by the V2 PO generation endpoint) and passed it to client.order_plan.query() which targets /api/OrderPlan and expects OrderPlanFilterCriteria (singular `location`/`supplier`/`category`). StockTrim rejected the wrong-shaped body with 415. Switch to the singular schema; for list-shaped MCP inputs, send the first element to the API and log `order_plan_multifilter_narrowed` so operators see that further filtering was dropped (multi-filter is an API limitation). Tests: - test_utils.py: 4 new cases for body-excerpt formatting (basic, long-body truncation, empty body, undecodable bytes). - test_urgent_orders.py: 2 new cases pinning the OrderPlanFilterCriteria type (vs Dto) and the multi-element warning. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be4657a commit 6cfdd98

4 files changed

Lines changed: 371 additions & 14 deletions

File tree

stocktrim_mcp_server/src/stocktrim_mcp_server/tools/workflows/urgent_orders.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,45 @@
1717
from stocktrim_mcp_server.tools.preferences import load_preferences, resolve
1818
from stocktrim_mcp_server.tools.tool_result_utils import make_json_result
1919
from stocktrim_mcp_server.utils import unwrap_unset
20-
from stocktrim_public_api_client.client_types import UNSET
20+
from stocktrim_public_api_client.client_types import UNSET, Unset
21+
from stocktrim_public_api_client.generated.models.order_plan_filter_criteria import (
22+
OrderPlanFilterCriteria,
23+
)
2124
from stocktrim_public_api_client.generated.models.order_plan_filter_criteria_dto import (
2225
OrderPlanFilterCriteriaDto,
2326
)
2427

2528
logger = get_logger(__name__)
2629

30+
31+
_NARROWED_LOG_PREVIEW = 5
32+
33+
34+
def _first_or_unset(values: list[str] | None, field_name: str) -> str | Unset:
35+
"""Narrow a list-shaped MCP request field down to a single API-side filter.
36+
37+
StockTrim's /api/OrderPlan accepts only one location/supplier per call,
38+
but the MCP request schema takes lists for forward compatibility. When
39+
the caller supplies multiple values we keep the first and log a warning
40+
so the call still succeeds (instead of 415-ing) and operators see that
41+
further narrowing happened. The warning payload only includes a
42+
bounded preview of the dropped values to keep structured logs small
43+
when callers pass long lists.
44+
"""
45+
if not values:
46+
return UNSET
47+
if len(values) > 1:
48+
dropped = values[1:]
49+
logger.warning(
50+
"order_plan_multifilter_narrowed",
51+
field=field_name,
52+
kept=values[0],
53+
dropped_count=len(dropped),
54+
dropped_preview=dropped[:_NARROWED_LOG_PREVIEW],
55+
)
56+
return values[0]
57+
58+
2759
# ============================================================================
2860
# Tool 1: review_urgent_order_requirements
2961
# ============================================================================
@@ -100,6 +132,7 @@ async def _review_urgent_order_requirements_impl(
100132
"""
101133
prefs = await load_preferences(context)
102134
days_threshold = resolve(request.days_threshold, prefs, "days_threshold", 30)
135+
category = resolve(request.category, prefs, "category", None)
103136
# Use `is None` (not truthiness) so an explicit empty list from the caller
104137
# clears the filter for this call instead of silently falling back to the
105138
# stored preference. Same pattern below for supplier_codes.
@@ -122,12 +155,22 @@ async def _review_urgent_order_requirements_impl(
122155
# Get services from context (note: order_plan not in service layer yet, uses client directly)
123156
services = get_services(context)
124157

125-
# Build filter criteria for order plan query
126-
# Note: order_plan.get_urgent_items() doesn't support all our filters,
127-
# so we'll query with filters and filter by days threshold ourselves
128-
filter_criteria = OrderPlanFilterCriteriaDto(
129-
location_codes=location_codes or UNSET,
130-
supplier_codes=supplier_codes or UNSET,
158+
# The /api/OrderPlan endpoint expects an OrderPlanFilterCriteria
159+
# (singular `location`/`supplier`/`category`), not the list-shaped
160+
# OrderPlanFilterCriteriaDto used by the V2 PO generation endpoint.
161+
# Passing the Dto here made StockTrim reject the body with 415
162+
# (bug surfaced 2026-05-26). The API only supports one location/one
163+
# supplier per call; the lists produced by the session-preference
164+
# fallback above are narrowed to the first element here (logged as
165+
# a warning) so the call no longer fails when callers supply more
166+
# than one value.
167+
location = _first_or_unset(location_codes, "location_codes")
168+
supplier = _first_or_unset(supplier_codes, "supplier_codes")
169+
170+
filter_criteria = OrderPlanFilterCriteria(
171+
location=location,
172+
supplier=supplier,
173+
category=category or UNSET,
131174
)
132175

133176
# Query order plan (uses client directly as order_plan not in service layer)

stocktrim_mcp_server/tests/test_tools/test_workflows/test_urgent_orders.py

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,116 @@ async def test_review_urgent_orders_with_cost_calculation(
203203
assert response.suppliers[0].total_estimated_cost == 1550.0
204204

205205

206+
@pytest.mark.asyncio
207+
async def test_review_urgent_orders_sends_singular_filter_criteria(
208+
mock_urgent_context, urgent_order_item
209+
):
210+
"""The /api/OrderPlan endpoint expects OrderPlanFilterCriteria (singular
211+
`location`/`supplier`/`category`), not OrderPlanFilterCriteriaDto. Sending
212+
the Dto returns 415 from StockTrim (bug surfaced 2026-05-26).
213+
"""
214+
from stocktrim_public_api_client.generated.models.order_plan_filter_criteria import (
215+
OrderPlanFilterCriteria,
216+
)
217+
218+
mock_client = mock_urgent_context.request_context.lifespan_context.client
219+
mock_client.order_plan.query.return_value = [urgent_order_item]
220+
221+
request = ReviewUrgentOrdersRequest(
222+
days_threshold=30,
223+
location_codes=["WH-01"],
224+
supplier_codes=["SUP-001"],
225+
category="Widgets",
226+
)
227+
await _review(request, mock_urgent_context)
228+
229+
mock_client.order_plan.query.assert_called_once()
230+
criteria = mock_client.order_plan.query.call_args.args[0]
231+
assert isinstance(criteria, OrderPlanFilterCriteria)
232+
# Singular fields mapped from the list-shaped request:
233+
assert criteria.location == "WH-01"
234+
assert criteria.supplier == "SUP-001"
235+
assert criteria.category == "Widgets"
236+
237+
238+
@pytest.mark.asyncio
239+
async def test_review_urgent_orders_narrows_multifilter_with_warning(
240+
mock_urgent_context, urgent_order_item
241+
):
242+
"""When multiple locations/suppliers are requested, only the first is
243+
sent to the API (which doesn't support multi-filter) and a warning is
244+
logged so operators see that the others were dropped."""
245+
import structlog.testing
246+
247+
mock_client = mock_urgent_context.request_context.lifespan_context.client
248+
mock_client.order_plan.query.return_value = [urgent_order_item]
249+
250+
request = ReviewUrgentOrdersRequest(
251+
days_threshold=30,
252+
location_codes=["WH-01", "WH-02"],
253+
supplier_codes=["SUP-001", "SUP-002", "SUP-003"],
254+
)
255+
with structlog.testing.capture_logs() as captured:
256+
await _review(request, mock_urgent_context)
257+
258+
criteria = mock_client.order_plan.query.call_args.args[0]
259+
assert criteria.location == "WH-01"
260+
assert criteria.supplier == "SUP-001"
261+
# Both narrowings should have surfaced as warnings.
262+
warnings = [
263+
r
264+
for r in captured
265+
if r.get("log_level") == "warning"
266+
and r.get("event") == "order_plan_multifilter_narrowed"
267+
]
268+
by_field = {r["field"]: r for r in warnings}
269+
assert set(by_field) == {"location_codes", "supplier_codes"}
270+
# Counts and bounded previews replace the raw `dropped` list to keep
271+
# structured logs small when callers pass long filter lists.
272+
assert by_field["location_codes"]["dropped_count"] == 1
273+
assert by_field["location_codes"]["dropped_preview"] == ["WH-02"]
274+
assert by_field["supplier_codes"]["dropped_count"] == 2
275+
assert by_field["supplier_codes"]["dropped_preview"] == ["SUP-002", "SUP-003"]
276+
277+
278+
@pytest.mark.asyncio
279+
async def test_review_urgent_orders_caps_warning_preview_for_long_lists(
280+
mock_urgent_context, urgent_order_item
281+
):
282+
"""A very long list should still narrow to one value, but the warning
283+
log only carries a bounded preview (not the full dropped list) so
284+
structured logs don't bloat for outlier inputs."""
285+
import structlog.testing
286+
287+
from stocktrim_mcp_server.tools.workflows.urgent_orders import (
288+
_NARROWED_LOG_PREVIEW,
289+
)
290+
291+
mock_client = mock_urgent_context.request_context.lifespan_context.client
292+
mock_client.order_plan.query.return_value = [urgent_order_item]
293+
294+
long_locations = [f"WH-{i:03d}" for i in range(50)]
295+
request = ReviewUrgentOrdersRequest(
296+
days_threshold=30, location_codes=long_locations
297+
)
298+
with structlog.testing.capture_logs() as captured:
299+
await _review(request, mock_urgent_context)
300+
301+
warning = next(
302+
r
303+
for r in captured
304+
if r.get("event") == "order_plan_multifilter_narrowed"
305+
and r.get("field") == "location_codes"
306+
)
307+
# 50 inputs → first kept, remaining 49 dropped; preview must be exactly
308+
# the impl's cap (not a looser bound — looser would silently regress).
309+
assert warning["dropped_count"] == 49
310+
assert len(warning["dropped_preview"]) == _NARROWED_LOG_PREVIEW
311+
# Preview starts at the right offset (first dropped element).
312+
assert warning["dropped_preview"][0] == "WH-001"
313+
assert warning["dropped_preview"][-1] == f"WH-{_NARROWED_LOG_PREVIEW:03d}"
314+
315+
206316
@pytest.mark.asyncio
207317
async def test_review_urgent_orders_filters_by_threshold(mock_urgent_context):
208318
"""Test that items are filtered by days_threshold."""
@@ -470,7 +580,8 @@ async def test_review_urgent_orders_explicit_arg_wins_over_pref(mock_urgent_cont
470580
@pytest.mark.asyncio
471581
async def test_review_urgent_orders_falls_back_to_pref_supplier(mock_urgent_context):
472582
"""When request.supplier_codes is omitted, the saved supplier_code
473-
preference is wrapped into a one-item list and applied to the query."""
583+
preference is applied to the (singular) OrderPlanFilterCriteria.supplier
584+
sent to the /api/OrderPlan endpoint."""
474585
from stocktrim_mcp_server.tools.preferences import SessionPreferences
475586

476587
pref = SessionPreferences(supplier_code="SUP-PREF")
@@ -485,7 +596,7 @@ async def test_review_urgent_orders_falls_back_to_pref_supplier(mock_urgent_cont
485596

486597
mock_client.order_plan.query.assert_called_once()
487598
filter_criteria = mock_client.order_plan.query.call_args.args[0]
488-
assert filter_criteria.supplier_codes == ["SUP-PREF"]
599+
assert filter_criteria.supplier == "SUP-PREF"
489600

490601

491602
@pytest.mark.asyncio
@@ -506,7 +617,7 @@ async def test_review_urgent_orders_explicit_supplier_codes_override_pref(
506617
await _review(request, mock_urgent_context)
507618

508619
filter_criteria = mock_client.order_plan.query.call_args.args[0]
509-
assert filter_criteria.supplier_codes == ["SUP-EXPLICIT"]
620+
assert filter_criteria.supplier == "SUP-EXPLICIT"
510621

511622

512623
@pytest.mark.asyncio
@@ -530,8 +641,8 @@ async def test_review_urgent_orders_explicit_empty_clears_pref(mock_urgent_conte
530641
# Empty list is falsy, so the helper sends UNSET (no filter) to the API.
531642
from stocktrim_public_api_client.client_types import UNSET
532643

533-
assert filter_criteria.location_codes is UNSET
534-
assert filter_criteria.supplier_codes is UNSET
644+
assert filter_criteria.location is UNSET
645+
assert filter_criteria.supplier is UNSET
535646

536647

537648
@pytest.mark.asyncio

stocktrim_public_api_client/utils.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
from .client_types import UNSET, Response, Unset
1111

12+
# How many characters of the raw response body to attach to error messages
13+
# when the OpenAPI client could not parse it. Bounded so HTML stack-traces
14+
# and similar verbose bodies don't blow up log lines / MCP tool errors, but
15+
# long enough for the prefix to actually identify the problem.
16+
_BODY_EXCERPT_LIMIT = 500
17+
1218
if TYPE_CHECKING:
1319
from .generated.models.problem_details import ProblemDetails
1420

@@ -67,6 +73,64 @@ class ServerError(APIError):
6773
pass
6874

6975

76+
def _is_unsafe_control_char(c: str) -> bool:
77+
"""Identify ASCII / C1 control chars that would corrupt log output.
78+
79+
Covers the C0 range (``\\x00``-``\\x1f``) and DEL plus the C1 range
80+
(``\\x7f``-``\\x9f``). Whitelists ``\\n``, ``\\r``, ``\\t`` — those are
81+
legitimate text formatting and useful inside JSON/HTML body excerpts.
82+
"""
83+
if c in "\n\r\t":
84+
return False
85+
code = ord(c)
86+
return code < 0x20 or 0x7F <= code <= 0x9F
87+
88+
89+
def _body_excerpt(response: "Response[T]") -> str | None:
90+
"""Return a short, printable excerpt of the response body for error messages.
91+
92+
Returns ``None`` when the body is empty (so callers can omit the trailing
93+
``: <excerpt>`` from the message). Bodies that aren't valid UTF-8 (binary
94+
blobs, etc.) fall back to a ``<N bytes, undecodable>`` placeholder so the
95+
exception text stays printable. Control characters in otherwise-text
96+
bodies (C0 range, C1 range, and DEL — see :func:`_is_unsafe_control_char`)
97+
are escaped with ``\\xNN`` so they don't corrupt log lines. When the
98+
body exceeds the limit a ``…[+N chars]`` suffix tells the caller how many
99+
source characters were dropped.
100+
101+
The output length is strictly bounded by ``_BODY_EXCERPT_LIMIT`` (plus
102+
the short ``…[+N chars]`` suffix). Without per-char accounting a body of
103+
all control characters would expand 4x to ``4 * LIMIT`` after escaping;
104+
the loop below stops adding chars once the *escaped* budget is spent and
105+
reports the unconsumed source length in the suffix instead.
106+
"""
107+
content = getattr(response, "content", None)
108+
if not content:
109+
return None
110+
try:
111+
text = content.decode("utf-8").strip()
112+
except UnicodeDecodeError:
113+
return f"<{len(content)} bytes, undecodable>"
114+
if not text:
115+
return None
116+
117+
pieces: list[str] = []
118+
output_len = 0
119+
consumed = 0
120+
for c in text:
121+
escaped_c = f"\\x{ord(c):02x}" if _is_unsafe_control_char(c) else c
122+
if output_len + len(escaped_c) > _BODY_EXCERPT_LIMIT:
123+
break
124+
pieces.append(escaped_c)
125+
output_len += len(escaped_c)
126+
consumed += 1
127+
128+
excerpt = "".join(pieces)
129+
if consumed == len(text):
130+
return excerpt
131+
return f"{excerpt}…[+{len(text) - consumed} chars]"
132+
133+
70134
@overload
71135
def unwrap(
72136
response: Response[T],
@@ -139,7 +203,10 @@ def unwrap(
139203
if not raise_on_error:
140204
return None
141205

142-
# Extract error message from ProblemDetails if available
206+
# Extract error message from ProblemDetails if available; fall back
207+
# to a generic message plus a body excerpt so 5xx responses with
208+
# unparseable bodies (HTML stack traces, plain-text errors, etc.) are
209+
# debuggable from the exception alone instead of opaque.
143210
if problem_details:
144211
title = unwrap_unset(problem_details.title)
145212
detail = unwrap_unset(problem_details.detail)
@@ -149,7 +216,9 @@ def unwrap(
149216
else (title or detail or "Unknown error")
150217
)
151218
else:
152-
message = f"API error with status {response.status_code}"
219+
base = f"API error with status {response.status_code}"
220+
excerpt = _body_excerpt(response)
221+
message = f"{base}: {excerpt}" if excerpt else base
153222

154223
# Raise specific exception based on status code
155224
if response.status_code == HTTPStatus.UNAUTHORIZED:

0 commit comments

Comments
 (0)