Skip to content

Commit 2471290

Browse files
jonpspriclaude
andcommitted
test(tool-service): cover _looks_like_mcp_envelope layer-2 branches and BaseModel fallback
Five new tests in ``TestCoerceToToolResult`` close the coverage gaps flagged by the tool_service.py coverage report (86.2% → higher on the helper): * ``test_accepts_dict_with_structured_content_marker_only`` — exercises the ``structuredContent`` positive-marker branch of ``_looks_like_mcp_envelope`` (was uncovered). * ``test_accepts_dict_with_snake_case_structured_content_marker`` — the snake-case sibling; forwarded-worker responses use this form. * ``test_accepts_dict_with_only_typed_content_items`` — the weakest positive marker (content items carrying a string ``type`` field); without this a future tightening could drop the branch silently. * ``test_rejects_dict_with_only_meta_marker`` — the final ``return False`` branch when all keys are envelope-shaped but no positive marker fires. ``_meta`` alone is not sufficient. * ``test_basemodel_with_content_that_fails_validation_falls_back`` — pins the ``except ValidationError`` branch inside ``_coerce_to_tool_result``'s BaseModel dispatch; asserts WARNING-level log with ``exc_info=True``. While writing the BaseModel fallback test it surfaced a real bug: a Pydantic ``BaseModel`` that failed ``ToolResult.model_validate`` and fell through to the opaque-JSON fallback caused ``orjson.dumps`` to raise ``TypeError`` — breaking the helper's "always returns a valid ToolResult" invariant. Fixed by dumping BaseModel payloads via ``model_dump(mode="json", by_alias=True)`` before serialising, and adding a last-resort ``TypeError`` catch that falls back to ``str(payload)`` with a WARNING log. ``test_non_json_serialisable_payload_falls_back_to_str`` pins the new branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent 8788c10 commit 2471290

File tree

3 files changed

+186
-14
lines changed

3 files changed

+186
-14
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:02:21Z",
6+
"generated_at": "2026-04-14T23:22:05Z",
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": 2731,
8669+
"line_number": 2887,
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": 3418,
8677+
"line_number": 3574,
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": 3427,
8685+
"line_number": 3583,
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": 3818,
8693+
"line_number": 3974,
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": 6746,
8701+
"line_number": 6902,
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": 6764,
8709+
"line_number": 6920,
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": 6788,
8717+
"line_number": 6944,
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": 6852,
8725+
"line_number": 7008,
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": 6863,
8733+
"line_number": 7019,
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": 6904,
8741+
"line_number": 7060,
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": 6910,
8749+
"line_number": 7066,
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": 8033,
8757+
"line_number": 8189,
87588758
"type": "Secret Keyword",
87598759
"verified_result": null
87608760
}

mcpgateway/services/tool_service.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1678,7 +1678,23 @@ def _coerce_to_tool_result(self, payload: Any) -> ToolResult:
16781678
# indicates an upstream whose shape the heuristic ought to learn
16791679
# to recognise).
16801680
logger.debug("Coercing %s payload to opaque text content", type(payload).__name__)
1681-
serialized = orjson.dumps(payload, option=orjson.OPT_INDENT_2)
1681+
# orjson cannot natively serialise arbitrary Pydantic ``BaseModel``
1682+
# instances. A BaseModel reaching this branch means it either
1683+
# failed the earlier ``ToolResult.model_validate`` step *or* did
1684+
# not expose a ``content`` attribute at all — dump it first so
1685+
# we don't raise ``TypeError`` and break the "always returns a
1686+
# valid ``ToolResult``" invariant.
1687+
serializable_payload = payload.model_dump(mode="json", by_alias=True) if isinstance(payload, BaseModel) else payload
1688+
try:
1689+
serialized = orjson.dumps(serializable_payload, option=orjson.OPT_INDENT_2)
1690+
except TypeError:
1691+
# Very last resort — something non-JSON-serialisable slipped
1692+
# through (e.g. ``set``, datetime without isoformat, a custom
1693+
# object without ``__json__``). Fall back to ``str(payload)``
1694+
# so the caller still sees *something* and the pipeline stays
1695+
# on contract.
1696+
logger.warning("Payload of type %s is not JSON-serialisable; using str() fallback", type(payload).__name__, exc_info=True)
1697+
return ToolResult(content=[TextContent(type="text", text=str(payload))])
16821698
return ToolResult(content=[TextContent(type="text", text=serialized.decode())])
16831699

16841700
async def register_tool(

tests/unit/mcpgateway/services/test_tool_service_coverage.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,162 @@ def test_accepts_dict_with_mcp_markers(self, tool_service):
22472247
assert result.structured_content == {"recognitionId": "rec-1"}
22482248
assert result.content[0].text == "ok"
22492249

2250+
def test_accepts_dict_with_structured_content_marker_only(self, tool_service):
2251+
"""Dict with ``structuredContent`` (camelCase) but no ``isError`` is an envelope.
2252+
2253+
Covers the ``structuredContent`` branch of ``_looks_like_mcp_envelope``'s
2254+
layer-2 positive-marker check (search the helper for ``"structuredContent" in keys"``).
2255+
This exercises the camelCase wire form; the snake-case sibling is
2256+
covered by the next test.
2257+
"""
2258+
# No ``isError`` — so the heuristic must admit via structuredContent.
2259+
payload = {
2260+
"content": [{"type": "text", "text": "ok"}],
2261+
"structuredContent": {"recognitionId": "rec-42"},
2262+
}
2263+
2264+
result = tool_service._coerce_to_tool_result(payload)
2265+
2266+
assert isinstance(result, ToolResult)
2267+
assert result.structured_content == {"recognitionId": "rec-42"}
2268+
assert result.content[0].text == "ok"
2269+
# ToolResult.is_error defaults to False when unset in the payload.
2270+
assert result.is_error is False
2271+
2272+
def test_accepts_dict_with_snake_case_structured_content_marker(self, tool_service):
2273+
"""Dict with ``structured_content`` (snake_case, gateway-internal) is an envelope.
2274+
2275+
Covers the ``structured_content`` half of the layer-2 check. Both
2276+
spellings live in ``_MCP_ENVELOPE_KEYS`` because forwarded-worker
2277+
responses and internal serialisations may emit either form.
2278+
"""
2279+
payload = {
2280+
"content": [{"type": "text", "text": "ok"}],
2281+
"structured_content": {"recognitionId": "rec-99"},
2282+
}
2283+
2284+
result = tool_service._coerce_to_tool_result(payload)
2285+
2286+
assert isinstance(result, ToolResult)
2287+
assert result.structured_content == {"recognitionId": "rec-99"}
2288+
2289+
def test_accepts_dict_with_only_typed_content_items(self, tool_service):
2290+
"""Dict whose only MCP signal is typed ``content`` items is still admitted.
2291+
2292+
Covers the weakest positive marker in ``_looks_like_mcp_envelope`` —
2293+
``content`` as a non-empty list of dicts each carrying a string
2294+
``type`` field (MCP ``ContentBlock`` shape). No ``isError``,
2295+
no ``structuredContent``, no ``_meta`` — this test exists so a
2296+
future tightening that accidentally drops the typed-content branch
2297+
is caught.
2298+
"""
2299+
payload = {
2300+
"content": [
2301+
{"type": "text", "text": "line 1"},
2302+
{"type": "text", "text": "line 2"},
2303+
],
2304+
}
2305+
2306+
result = tool_service._coerce_to_tool_result(payload)
2307+
2308+
assert isinstance(result, ToolResult)
2309+
assert len(result.content) == 2
2310+
assert result.content[0].text == "line 1"
2311+
assert result.content[1].text == "line 2"
2312+
2313+
def test_rejects_dict_with_only_meta_marker(self, tool_service):
2314+
"""Dict with only ``_meta`` and no positive signal is not an MCP envelope.
2315+
2316+
Covers the final ``return False`` branch of
2317+
``_looks_like_mcp_envelope`` — all keys are in the envelope set
2318+
(layer 1 passes) but no positive marker fires (``isError`` /
2319+
``structuredContent`` / typed content items). ``_meta`` alone
2320+
doesn't make a payload MCP-shaped. The payload is therefore
2321+
coerced via the opaque-JSON fallback so the caller can inspect
2322+
the ``_meta`` contents without them being reinterpreted as
2323+
protocol metadata.
2324+
"""
2325+
payload = {"_meta": {"trace_id": "abc"}, "content": []}
2326+
2327+
result = tool_service._coerce_to_tool_result(payload)
2328+
2329+
# Falls through to text-serialisation; ``_meta`` preserved as data.
2330+
assert isinstance(result, ToolResult)
2331+
assert result.content[0].type == "text"
2332+
roundtripped = orjson.loads(result.content[0].text)
2333+
assert roundtripped == payload
2334+
2335+
def test_non_json_serialisable_payload_falls_back_to_str(self, tool_service, caplog):
2336+
"""``orjson.dumps`` TypeError is caught and we fall back to ``str(payload)``.
2337+
2338+
``_coerce_to_tool_result``'s contract is to always return a valid
2339+
``ToolResult``, even for payloads that aren't natively JSON-
2340+
serialisable (e.g. sets, circular references, custom objects
2341+
without a ``__json__`` method). The last-resort ``TypeError``
2342+
fallback at the end of the helper catches the ``orjson.dumps``
2343+
failure, logs at WARNING with ``exc_info=True``, and wraps
2344+
``str(payload)`` into a single ``TextContent``. Without this
2345+
guard the helper would raise and break the pipeline's
2346+
"always-produces-a-ToolResult" invariant.
2347+
"""
2348+
2349+
class NotJSONSerialisable:
2350+
"""Plain Python class with no JSON representation."""
2351+
2352+
def __repr__(self) -> str:
2353+
return "NotJSONSerialisable(opaque)"
2354+
2355+
payload = NotJSONSerialisable()
2356+
2357+
with caplog.at_level("WARNING", logger="mcpgateway.services.tool_service"):
2358+
result = tool_service._coerce_to_tool_result(payload)
2359+
2360+
assert isinstance(result, ToolResult)
2361+
assert result.content[0].type == "text"
2362+
# ``str(payload)`` should be visible in the fallback text.
2363+
assert "NotJSONSerialisable" in result.content[0].text
2364+
relevant = [r for r in caplog.records if "not JSON-serialisable" in r.message]
2365+
assert relevant, "Expected a WARNING-level log from the str() fallback"
2366+
assert relevant[-1].levelname == "WARNING"
2367+
assert relevant[-1].exc_info is not None
2368+
2369+
def test_basemodel_with_content_that_fails_validation_falls_back(self, tool_service, caplog):
2370+
"""Pydantic ``BaseModel`` path that fails ``ToolResult`` validation falls back to text.
2371+
2372+
Covers the ``except ValidationError`` branch inside
2373+
``_coerce_to_tool_result``'s BaseModel dispatch. A caller could
2374+
conceivably pass a non-MCP Pydantic model that happens to expose
2375+
a ``content`` attribute but whose ``model_dump(by_alias=True)``
2376+
produces a shape that ``ToolResult.model_validate`` refuses
2377+
(e.g. ``content`` is a scalar rather than a list).
2378+
2379+
The helper must not raise — it must log at WARNING (so operators
2380+
see the protocol/schema drift) and fall through to the opaque
2381+
JSON-serialisation path so the rest of the pipeline still gets
2382+
a valid ``ToolResult``.
2383+
"""
2384+
# Third-Party
2385+
from pydantic import BaseModel # pylint: disable=import-outside-toplevel
2386+
2387+
class BadUpstreamModel(BaseModel):
2388+
"""Stand-in for a third-party model that has ``content`` but wrong shape."""
2389+
2390+
content: int = 42 # ``ToolResult.content`` requires ``list[ContentBlock]``
2391+
2392+
bad_model = BadUpstreamModel()
2393+
2394+
with caplog.at_level("WARNING", logger="mcpgateway.services.tool_service"):
2395+
result = tool_service._coerce_to_tool_result(bad_model)
2396+
2397+
assert isinstance(result, ToolResult)
2398+
assert result.content[0].type == "text"
2399+
# The WARNING-severity reshape log must have fired (see #4202 egress
2400+
# review round — user-visible reshapes promoted from DEBUG).
2401+
relevant = [r for r in caplog.records if "did not validate as ToolResult" in r.message]
2402+
assert relevant, "Expected a WARNING-level reshape log from the BaseModel ValidationError branch"
2403+
assert relevant[-1].levelname == "WARNING"
2404+
assert relevant[-1].exc_info is not None, "Expected exc_info=True for post-mortem diagnosis"
2405+
22502406
def test_validation_error_falls_back_to_text_serialization(self, tool_service, caplog):
22512407
"""Dicts admitted by the heuristic but rejected by Pydantic fall back to text.
22522408

0 commit comments

Comments
 (0)