Skip to content

Commit 95ece4f

Browse files
jonpspriclaude
andcommitted
fix(tool-service): harden _coerce_to_tool_result last-resort fallback against broken str/repr
Codex stop-review found that the prior fix still had escape hatches: ``type(payload).__name__``, ``str(payload)`` and ``repr(payload)`` all live inside the last-resort fallback but none were individually guarded, so a payload whose ``__str__`` and ``__repr__`` both raise (or whose ``type().__name__`` lookup fails) could still escape the helper and break the "never raises" invariant. Extract two module-level helpers with staged defensive fallbacks: * ``_safe_type_name(obj)`` — returns ``type(obj).__name__`` or ``"<untypeable>"`` sentinel, never raises. * ``_safe_text_repr(obj, fallback_type)`` — tries ``str`` → ``repr`` → ``f"<{fallback_type} object (unrepresentable)>"``. Guarantees a ``str`` return on every branch; each coercion attempt is try/except-guarded, and the final tier is a pre-computed literal so even a metaclass whose ``__name__`` property raises cannot propagate. ``_coerce_to_tool_result``'s opaque-JSON fallback now computes ``payload_type`` via ``_safe_type_name`` once up front and passes it both to the DEBUG/WARNING log sites and to ``_safe_text_repr``. Regression guard: ``test_payload_with_broken_str_and_repr_still_produces_toolresult`` exercises an ``AdversarialPayload`` whose ``__str__`` and ``__repr__`` both raise ``RuntimeError``. Against the pre-fix code either the ``str(payload)`` call or the fall-through ``repr(payload)`` would propagate; against the new shape the helper emits the third-tier sentinel text and a WARNING log. Empirically validated via an in-line stress test of ``_safe_type_name`` and ``_safe_text_repr`` against a metaclass with a ``__name__`` property that raises. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent 699361b commit 95ece4f

File tree

3 files changed

+126
-36
lines changed

3 files changed

+126
-36
lines changed

.secrets.baseline

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|go.sum|mcpgateway/sri_hashes.json|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-14T23:25:53Z",
6+
"generated_at": "2026-04-14T23:29:31Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -8666,95 +8666,95 @@
86668666
"hashed_secret": "2dd2850bcc4ae4e74154aed99ee5f68292ecfec5",
86678667
"is_secret": false,
86688668
"is_verified": false,
8669-
"line_number": 2936,
8669+
"line_number": 2980,
86708670
"type": "Secret Keyword",
86718671
"verified_result": null
86728672
},
86738673
{
86748674
"hashed_secret": "d92342976d720ff38cf5dcb329be41959ab1ba6c",
86758675
"is_secret": false,
86768676
"is_verified": false,
8677-
"line_number": 3623,
8677+
"line_number": 3667,
86788678
"type": "Secret Keyword",
86798679
"verified_result": null
86808680
},
86818681
{
86828682
"hashed_secret": "f93640458964af294a485bc6d597694cf3308e1f",
86838683
"is_secret": false,
86848684
"is_verified": false,
8685-
"line_number": 3632,
8685+
"line_number": 3676,
86868686
"type": "Secret Keyword",
86878687
"verified_result": null
86888688
},
86898689
{
86908690
"hashed_secret": "0857abc2d90c5d9821229fc0a880f1c38ffb0e04",
86918691
"is_secret": false,
86928692
"is_verified": false,
8693-
"line_number": 4023,
8693+
"line_number": 4067,
86948694
"type": "Hex High Entropy String",
86958695
"verified_result": null
86968696
},
86978697
{
86988698
"hashed_secret": "99834bc4eff3f1e1c1e4692d2476b593b501d045",
86998699
"is_secret": false,
87008700
"is_verified": false,
8701-
"line_number": 6951,
8701+
"line_number": 6995,
87028702
"type": "Secret Keyword",
87038703
"verified_result": null
87048704
},
87058705
{
87068706
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
87078707
"is_secret": false,
87088708
"is_verified": false,
8709-
"line_number": 6969,
8709+
"line_number": 7013,
87108710
"type": "Secret Keyword",
87118711
"verified_result": null
87128712
},
87138713
{
87148714
"hashed_secret": "b8cec023ad982a1355abb9e7c7700e42d7e6fac3",
87158715
"is_secret": false,
87168716
"is_verified": false,
8717-
"line_number": 6993,
8717+
"line_number": 7037,
87188718
"type": "Secret Keyword",
87198719
"verified_result": null
87208720
},
87218721
{
87228722
"hashed_secret": "0614eb27c6e0d2f4ed9a1cce2a5bcbbbc17aa556",
87238723
"is_secret": false,
87248724
"is_verified": false,
8725-
"line_number": 7057,
8725+
"line_number": 7101,
87268726
"type": "Secret Keyword",
87278727
"verified_result": null
87288728
},
87298729
{
87308730
"hashed_secret": "a38fea79a043f0c9a62f851659d459dc3b716ea9",
87318731
"is_secret": false,
87328732
"is_verified": false,
8733-
"line_number": 7068,
8733+
"line_number": 7112,
87348734
"type": "Secret Keyword",
87358735
"verified_result": null
87368736
},
87378737
{
87388738
"hashed_secret": "a50da1fb101595ac5158f2c9394a65a84061b56c",
87398739
"is_secret": false,
87408740
"is_verified": false,
8741-
"line_number": 7109,
8741+
"line_number": 7153,
87428742
"type": "Secret Keyword",
87438743
"verified_result": null
87448744
},
87458745
{
87468746
"hashed_secret": "ed3e0017cb8e4b06a59af1a441f62cbe58d2ef59",
87478747
"is_secret": false,
87488748
"is_verified": false,
8749-
"line_number": 7115,
8749+
"line_number": 7159,
87508750
"type": "Secret Keyword",
87518751
"verified_result": null
87528752
},
87538753
{
87548754
"hashed_secret": "9bdc408babb4c5740aa9ee42e65b9308bd01f5f9",
87558755
"is_secret": false,
87568756
"is_verified": false,
8757-
"line_number": 8238,
8757+
"line_number": 8282,
87588758
"type": "Secret Keyword",
87598759
"verified_result": null
87608760
}

mcpgateway/services/tool_service.py

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,61 @@ def _looks_like_mcp_envelope(payload: dict) -> bool:
371371
return False
372372

373373

374+
def _safe_type_name(obj: Any) -> str:
375+
"""Return ``type(obj).__name__``, or a sentinel if that itself raises.
376+
377+
Used inside :meth:`ToolService._coerce_to_tool_result`'s last-resort
378+
fallback so logging and diagnostic text on a pathological object
379+
(broken proxy, ``__class__`` descriptor that raises, etc.) cannot
380+
themselves raise and escape the "always returns a valid
381+
``ToolResult``" invariant.
382+
"""
383+
try:
384+
return type(obj).__name__
385+
except Exception: # pylint: disable=broad-except
386+
return "<untypeable>"
387+
388+
389+
def _safe_text_repr(obj: Any, fallback_type: str) -> str:
390+
"""Coerce an arbitrary object to a non-raising text representation.
391+
392+
Tries ``str(obj)``, then ``repr(obj)``, then ``f"<{fallback_type}
393+
object>"``, and finally a fixed sentinel. Used by the opaque-JSON
394+
last-resort branch of
395+
:meth:`ToolService._coerce_to_tool_result` — neither ``str`` nor
396+
``repr`` is guaranteed to succeed on arbitrary Python objects (a
397+
class can override either to raise), and without this staged
398+
fallback a malicious or simply buggy payload could escape the
399+
helper and break the "never raises" contract downstream code relies
400+
on.
401+
402+
Args:
403+
obj: The payload to stringify.
404+
fallback_type: Pre-computed type name used in the third-tier
405+
sentinel; keeps ``type().__name__`` out of this function's
406+
hot path since it has its own :func:`_safe_type_name`
407+
guard upstream.
408+
409+
Returns:
410+
A ``str``. Never raises.
411+
"""
412+
try:
413+
text = str(obj)
414+
if isinstance(text, str):
415+
return text
416+
except Exception: # pylint: disable=broad-except
417+
pass
418+
try:
419+
text = repr(obj)
420+
if isinstance(text, str):
421+
return text
422+
except Exception: # pylint: disable=broad-except
423+
pass
424+
# ``fallback_type`` came from ``_safe_type_name`` so it's
425+
# guaranteed to be a ``str`` already.
426+
return f"<{fallback_type} object (unrepresentable)>"
427+
428+
374429
def _handle_json_parse_error(response, error, is_error_response: bool = False) -> dict:
375430
"""Handle JSON parsing failures with graceful fallback to raw text.
376431
@@ -1673,36 +1728,27 @@ def _coerce_to_tool_result(self, payload: Any) -> ToolResult:
16731728

16741729
# Last resort — opaque JSON body. Preserves the payload for the
16751730
# caller to inspect while keeping the rest of the pipeline on a
1676-
# uniform ``ToolResult`` contract. Log at DEBUG so operators can
1677-
# spot integrations regularly hitting this branch (which usually
1678-
# indicates an upstream whose shape the heuristic ought to learn
1679-
# to recognise).
1680-
logger.debug("Coercing %s payload to opaque text content", type(payload).__name__)
1681-
# Both ``BaseModel.model_dump`` and ``orjson.dumps`` can raise on
1682-
# pathological inputs: a BaseModel with a custom field serialiser
1683-
# that throws, a ``PydanticSerializationError`` (``ValueError``
1684-
# subclass, not ``TypeError``), or a plain Python object that
1685-
# isn't JSON-representable. Wrap the whole dump + serialise
1686-
# sequence so the helper's "always returns a valid ``ToolResult``"
1687-
# invariant holds even in the presence of upstream bugs.
1731+
# uniform ``ToolResult`` contract. The helper's invariant is
1732+
# "always returns a valid ``ToolResult``, never raises", so every
1733+
# step below must be guarded — both ``BaseModel.model_dump`` and
1734+
# ``orjson.dumps`` can raise on pathological inputs (custom
1735+
# serialisers that throw, ``PydanticSerializationError`` —
1736+
# ``ValueError`` subclass, not ``TypeError`` — fields holding
1737+
# non-representable objects), and even ``str()`` / ``repr()`` /
1738+
# ``type().__name__`` can fail on sufficiently broken proxy
1739+
# objects.
1740+
payload_type = _safe_type_name(payload)
1741+
logger.debug("Coercing %s payload to opaque text content", payload_type)
16881742
try:
16891743
serializable_payload = payload.model_dump(mode="json", by_alias=True) if isinstance(payload, BaseModel) else payload
16901744
serialized = orjson.dumps(serializable_payload, option=orjson.OPT_INDENT_2)
16911745
except Exception: # pylint: disable=broad-except
1692-
# Last-resort ``str(payload)`` so callers still see *something*.
1693-
# ``str()`` on a pathological object could itself raise, but
1694-
# ``repr()`` on an arbitrary Python object is effectively
1695-
# guaranteed by the runtime — keep the fallback defensive.
16961746
logger.warning(
1697-
"Payload of type %s could not be JSON-serialised; using str() fallback",
1698-
type(payload).__name__,
1747+
"Payload of type %s could not be JSON-serialised; using textual fallback",
1748+
payload_type,
16991749
exc_info=True,
17001750
)
1701-
try:
1702-
text = str(payload)
1703-
except Exception: # pylint: disable=broad-except
1704-
text = repr(payload)
1705-
return ToolResult(content=[TextContent(type="text", text=text)])
1751+
return ToolResult(content=[TextContent(type="text", text=_safe_text_repr(payload, payload_type))])
17061752
return ToolResult(content=[TextContent(type="text", text=serialized.decode())])
17071753

17081754
async def register_tool(

tests/unit/mcpgateway/services/test_tool_service_coverage.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,6 +2332,50 @@ def test_rejects_dict_with_only_meta_marker(self, tool_service):
23322332
roundtripped = orjson.loads(result.content[0].text)
23332333
assert roundtripped == payload
23342334

2335+
def test_payload_with_broken_str_and_repr_still_produces_toolresult(self, tool_service, caplog):
2336+
"""Even objects whose ``__str__`` *and* ``__repr__`` raise yield a ToolResult.
2337+
2338+
The helper's hardest invariant is "never raises, always returns
2339+
a valid ``ToolResult``". This test exercises the extreme tail of
2340+
the opaque-JSON last-resort branch: a payload that (1) is not a
2341+
BaseModel with MCP-shaped fields, (2) isn't JSON-serialisable
2342+
(so ``orjson.dumps`` raises), (3) whose ``__str__`` raises when
2343+
the helper tries ``str(payload)``, and (4) whose ``__repr__``
2344+
also raises. Without ``_safe_text_repr``'s third-tier
2345+
``f"<{type_name} object>"`` sentinel, either of those would
2346+
escape the helper.
2347+
2348+
Pairs with ``test_non_json_serialisable_payload_falls_back_to_str``
2349+
(normal ``str()`` path) and
2350+
``test_basemodel_whose_model_dump_raises_falls_back_to_str``
2351+
(BaseModel.model_dump raising).
2352+
"""
2353+
2354+
class AdversarialPayload:
2355+
"""Payload whose textual representations both raise."""
2356+
2357+
def __str__(self) -> str: # pragma: no cover - intentionally explodes
2358+
raise RuntimeError("__str__ is broken")
2359+
2360+
def __repr__(self) -> str: # pragma: no cover - intentionally explodes
2361+
raise RuntimeError("__repr__ is also broken")
2362+
2363+
payload = AdversarialPayload()
2364+
2365+
with caplog.at_level("WARNING", logger="mcpgateway.services.tool_service"):
2366+
result = tool_service._coerce_to_tool_result(payload)
2367+
2368+
# Contract held — we got a ``ToolResult`` rather than an exception.
2369+
assert isinstance(result, ToolResult)
2370+
assert result.content[0].type == "text"
2371+
# Third-tier sentinel used; must be a non-empty string.
2372+
assert "AdversarialPayload" in result.content[0].text
2373+
assert "unrepresentable" in result.content[0].text
2374+
# WARNING-severity reshape log should still have fired.
2375+
relevant = [r for r in caplog.records if "could not be JSON-serialised" in r.message]
2376+
assert relevant, "Expected a WARNING-level log from the last-resort fallback"
2377+
assert relevant[-1].levelname == "WARNING"
2378+
23352379
def test_basemodel_whose_model_dump_raises_falls_back_to_str(self, tool_service, caplog):
23362380
"""``BaseModel.model_dump`` raising is caught by the last-resort handler.
23372381

0 commit comments

Comments
 (0)