Repository navigation
fix(tools): return MCP-compliant error responses for tool execution failures - #6181
Conversation
|
Hi @msureshkumar88! Can you take a look into this? |
|
Tracking issue for this fix: #6275 |
jonpspri
left a comment
There was a problem hiding this comment.
PR Review
Good fix — the core change is correct and the five affected test updates all verify the right behaviour. A few things worth addressing before merge:
Blocking
Stale Raises: docstring in invoke_tool_direct — the docstring at line ~3733 still lists ToolInvocationError: If invocation fails. under Raises:, but the exception is now swallowed and converted to a CallToolResult for all runtime errors. This is a documentation contract violation: callers reading the docstring may still guard against an exception that will never arrive. The Raises: entry should either be removed or narrowed to the pre-execution checks that do still raise (gateway not found, not in direct_proxy mode, access denied).
The same inline comment block added at line ~3841 says "Raising ToolInvocationError would result in..." — since the raise is now gone, tightening to "Previously raised ToolInvocationError, which..." would be clearer.
Suggestions
apilabel — this label reads as REST API work;mcp-protocol(already present) is the right scope label here. Worth droppingapito keep the label set accurate.ROOT_CAUSE_ANALYSIS_GATEWAY_BUG.md— the checklist mentions this file was created, but it's not visible in the diff. If it's being added, it should appear in the changed files; if it's a local artifact not intended for the repo, remove it from the checklist item.- Dual attribute check in tests —
getattr(result, 'is_error', None) or getattr(result, 'isError', None)appears in four test assertions. Since the PR always returnstypes.CallToolResult(which uses camelCaseisError), theis_errorbranch will never be hit. Simplifying toresult.isError(matching the existingtest_invoke_tool_direct_connection_errorpattern) would make the intent clearer.
What's good
- The fix correctly targets only the five runtime-error paths while preserving exception raising for pre-execution errors (auth, validation, config) — that boundary is well-reasoned and the PR description explains it clearly.
- The
invoke_toolcaller at lines 6406–6428 already handles returnedCallToolResultvalues correctly via_coerce_to_tool_result, so no callsite changes are needed. asyncio.CancelledErrorcontinues to propagate in all transports — correct, that must never be swallowed.- Five tests updated, one per changed error path — good coverage.
Tracking issue: #6275
|
Thanks, @jonpspri! All blocking issues and suggestions have been addressed:
Changes committed in f41c538. Note: The failing test is unrelated to my changes. |
f41c538 to
2677a78
Compare
|
Overlap with This fix and the
Two consequences:
Why merge now, not wait for
|
msureshkumar88
left a comment
There was a problem hiding this comment.
Review — changes requested
The core fix (returning CallToolResult(isError=True) instead of raising) is the correct direction and matches the MCP spec. Two items need fixing before merge; the rest are should-fix, not blockers.
Blocking
1. mcpgateway/services/tool_service.py:3957-3964 — unsanitized exception text returned to client (CWE-209, information exposure)
invoke_tool_direct's new error path returns f"MCP server error: {str(e)}" verbatim to the MCP client. The sibling SSE/StreamableHTTP handlers three lines away route the equivalent message through sanitize_exception_message(str(root_cause), gateway_auth_query_params_decrypted) (tool_service.py:6525, 6720) before returning it. sanitize_exception_message (mcpgateway/utils/url_auth.py:182) exists specifically to redact auth query params embedded in exception text.
Before this PR, invoke_tool_direct's exception never reached the client — mcpgateway/transports/streamablehttp_transport.py:1848-1850 caught it and substituted the generic string "Direct proxy tool invocation failed". This PR is a regression on that path: a failed connection to a gateway with a token/API key in its URL can now return that URL, unredacted, in the tool result.
Fix: route the message through sanitize_exception_message before constructing the CallToolResult, matching the SSE/StreamableHTTP paths.
2. mcpgateway/services/tool_service.py:6515, 6710 — retry_on_status loses its input on this path
The outer handler at tool_service.py:7157-7181 builds structured_content={"status_code": root_cause.response.status_code} from httpx.HTTPStatusError specifically so the retry plugin can honor retry_on_status (comment at 7171-7173). Converting the inner except BaseException from raise to return CallToolResult(...) means SSE/StreamableHTTP failures no longer reach that outer handler, so a plugin configured with retry_on_status: [429, 503] can no longer see the status code for these transports. (Retry-on-timeout is unaffected — _run_timeout_post_invoke raises its own ToolTimeoutError with retry_delay_ms independently at tool_service.py:5007, and that path is untouched by this change.)
Fix: attach the same structured_content in the inner handler before returning, or re-raise so the outer handler retains ownership of both the retry decision and the final result shape.
Functionally-impacting
3. mcpgateway/services/tool_service.py:6515, 6710 — except BaseException now swallows SystemExit/GeneratorExit/KeyboardInterrupt
Only CancelledError is re-raised above the catch-all (6512, 6707); every other BaseException — including process-control signals — is now converted into an ordinary isError=True tool result instead of propagating. This repo has a documented prior incident with a related failure mode: tool_service.py:7015-7019 explicitly re-raises CancelledError with a comment citing "the swallowed-cancellation bug from PR #3202 review B7." Recommend adding the same explicit guard for SystemExit/GeneratorExit/KeyboardInterrupt here.
Should-fix (non-blocking)
tool_service.py:6355-6358,6551-6554— docstrings forconnect_to_sse_server/connect_to_streamablehttp_serverstill documentRaises: ToolInvocationError / ToolTimeoutError / BaseException, but these paths now return instead of raising for those cases.tool_service.py:3964,6511,6539,6706,6734— theCallToolResult(content=[TextContent(...)], isError=True)construction (with its explanatory comment) is duplicated five times. A small helper would keep future changes to the error shape (e.g. addingstructured_content, per finding #2) from being applied inconsistently.- No test covers the sanitization gap in finding #1 or the
retry_on_statusregression in finding #2. - No issue number referenced in the PR description.
2677a78 to
5ef724e
Compare
|
Thanks for the thorough review, @msureshkumar88! All findings are addressed in the latest commit ( Blocking #1 (CWE-209) — Blocking #2 (retry_on_status) — both SSE and StreamableHTTP inner Functionally-impacting #3 — added an explicit Should-fixes — docstrings for Tests — added |
msureshkumar88
left a comment
There was a problem hiding this comment.
Re-review of prior rounds
Verified against commits, not comments.
- Review 1 (Aug 17): addressed, except
apilabel still attached and no issue number referenced (both open suggestions). - Review 2 (Sep 18, CHANGES_REQUESTED):
- Finding #1 (CWE-209 unsanitized error text in
invoke_tool_direct): fixed correctly, routes throughsanitize_exception_message()with a dedicated test. - Finding #2 (
retry_on_statuslosingstructured_content): fixed for one code path only, still broken on another (see Finding 2 below). - Finding #3 (
SystemExit/GeneratorExit/KeyboardInterruptswallowed): fixed correctly.
- Finding #1 (CWE-209 unsanitized error text in
Fresh findings
Blocking
1. Unretried MCP timeout fires TOOL_POST_INVOKE twice (SSE + StreamableHTTP) — tool_service.py:6554, :6755
The timeout handlers changed raise ToolTimeoutError(...) to return self._make_mcp_tool_error(...). The return now falls through into the normal post-process block at :7085-7099, which fires TOOL_POST_INVOKE a second time after _run_timeout_post_invoke() (:5038) already fired it once for the same timeout.
This breaks an invariant the codebase documents explicitly at :7174-7176:
except ToolTimeoutError as e:
# ToolTimeoutError is raised by timeout handlers which already called tool_post_invoke.
# Do NOT call post_invoke again — the retry_delay_ms signal is carried on the exception.
Verified empirically: reproduced the PR's own SSE timeout test with a hook-call counter — TOOL_POST_INVOKE fired 2 times, expected 1. Any post-invoke plugin (circuit breaker, rate limiter, audit log, retry plugin) double-counts the failure, and the second hook's retry_delay_ms can trigger a second, unintended retry decision at :7129-7130 — the exact double-retry the :7175 comment exists to prevent. The gRPC timeout path (:7074-7078) still raises correctly; only the two MCP transports regressed.
Fix: keep the return value but short-circuit before the post-process block reaches :7089 for this path, or restore the raise and let the existing ToolTimeoutError handler at :7174 build the MCP-compliant response.
2. structured_content kwarg silently dropped on the direct-proxy retry path — tool_service.py:3834-3838
_make_mcp_tool_error builds types.CallToolResult (the MCP SDK type) but passes structured_content=. That SDK model's field is structuredContent, with no snake_case alias, and extra="allow" — so the value lands as an unrelated extra attribute instead of the declared field.
Verified empirically:
r = types.CallToolResult(..., structured_content={"status_code": 429})
r.structuredContent # -> None
Traced through ToolService._coerce_to_tool_result (the path invoke_tool's is_direct_proxy branch at :6853-6854 uses): the resulting structured_content is also None.
This silently re-breaks review 2's finding #2, but only for gateway_mode="direct_proxy" SSE/StreamableHTTP invocations. The non-direct-proxy branch happens to survive by accident, via an unrelated pre-existing fallback at :6862 (dump.get("structuredContent") or dump.get("structured_content")) — which is exactly the branch the PR's two new tests exercise, so they pass while the direct-proxy branch stays broken.
Fix: types.CallToolResult(..., structuredContent=structured_content).
Should-fix (non-blocking)
3. PR checklist claims - [x] Documentation updated (ROOT_CAUSE_ANALYSIS_GATEWAY_BUG.md created) — no such file exists in the diff or repo. Flagged in review 1 on Aug 17, still open a month later.
4. No issue number referenced (review 1 cited a tracking issue). api label still attached despite mcp-protocol already covering this work — review 1 asked to drop it.
5. tool_service.py:5016-5017 — _run_timeout_post_invoke docstring still says "otherwise control returns to the caller which raises a plain ToolTimeoutError." No longer true for the SSE/StreamableHTTP callers after this PR's change (see Finding 1).
Test coverage gap
The two new tests (TestInvokeToolMcpSseRetryOnStatus, TestInvokeToolMcpStreamableHttpRetryOnStatus) only exercise the non-direct-proxy branch and assert invoke_hook.assert_awaited() (at-least-once) rather than assert_awaited_once(), so neither Finding 1 nor Finding 2 is caught by the current suite. Please add:
- an assertion that
TOOL_POST_INVOKEfires exactly once on an unretried MCP timeout (both transports) - a direct-proxy
HTTPStatusErrorcase assertingstructured_content == {"status_code": 429}survives_coerce_to_tool_result
Other checklist items (no issue found)
No Alembic migration touched or needed — no schema/model change. Backend-only, no UI. No dead code introduced. Checked all callers of invoke_tool_direct/invoke_tool for code outside this PR that catches ToolInvocationError/ToolTimeoutError from these specific runtime paths — none exist; blast radius is contained to this PR's own callers.
|
Thanks for the re-review, @msureshkumar88. Here is what each finding got: Blocking #1 (double TOOL_POST_INVOKE on SSE/StreamableHTTP timeout) Blocking #2 ( Should-fix #5 (stale docstring) Test gaps
|
MCP protocol requires all tool results to include a 'content' field with an 'isError' flag for failures. Previously, runtime errors (server crashes, timeouts, network failures) raised exceptions that were converted to JSON-RPC errors, causing clients to fail with 'content: Field required' validation errors. Changes: - Modified invoke_tool_direct to return CallToolResult with isError=true instead of raising ToolInvocationError for connection/execution failures - Updated SSE transport timeout handler to return structured error response - Updated SSE transport exception handler to return structured error response - Updated StreamableHTTP transport timeout handler to return structured error - Updated StreamableHTTP transport exception handler to return structured error - Updated affected tests to expect structured error responses instead of exceptions All MCP execution paths now return properly structured CallToolResult objects even when the upstream server fails, ensuring protocol compliance and better error handling for MCP clients like langchain-mcp-adapters. Pre-execution errors (auth failures, validation errors, configuration issues) continue to raise exceptions as appropriate since they prevent execution from starting. Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
- Update invoke_tool_direct docstring to reflect runtime error handling - Clarify inline comment about previous exception behavior - Simplify test assertions to use result.isError directly Addresses review feedback on PR fixing MCP protocol violation (#6275) Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
- Fix #1 (blocking/CWE-209): route invoke_tool_direct exception text through sanitize_exception_message before returning in CallToolResult; snapshot gateway.auth_query_params inside the ORM session so secrets embedded in gateway URLs are redacted before reaching the client. - Fix #2 (blocking/retry_on_status): attach structured_content with status_code to CallToolResult in the SSE and StreamableHTTP inner BaseException handlers so the retry plugin can honour retry_on_status (e.g. 429, 503) without reaching the outer handler. - Fix #3 (functionally-impacting): re-raise SystemExit, GeneratorExit, and KeyboardInterrupt before the BaseException catch-all in both SSE and StreamableHTTP handlers; mirrors the existing CancelledError guard (PR #3202 review B7). - Should-fix: update connect_to_sse_server and connect_to_streamablehttp_server docstrings to reflect that these functions now return instead of raising for runtime failures. - Should-fix: extract _make_mcp_tool_error() static helper to eliminate five duplicate CallToolResult(isError=True) constructions; structured_ content is now threaded through a single site. - Tests: add test_invoke_tool_direct_error_is_sanitized to cover finding #1; add test_sse/streamablehttp_http_status_error_sets_structured_ content_status_code to cover finding #2. Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
…hs and fix structuredContent kwarg - SSE and StreamableHTTP timeout handlers were returning self._make_mcp_tool_error() instead of raising ToolTimeoutError. The return value fell through into the post-process block, firing TOOL_POST_INVOKE a second time after _run_timeout_post_invoke() had already fired it once. Restore raise ToolTimeoutError() so the existing outer handler (line 7174) is the sole post-invoke callsite for the timeout path. - _make_mcp_tool_error() passed structured_content= (snake_case) to types.CallToolResult, whose field is structuredContent (camelCase) with no alias and extra='allow'. The value was stored as an unrecognised extra attribute, so _coerce_to_tool_result() dropped it and structured_content was always None on the direct-proxy path. Fix: use structuredContent= kwarg. - Update _run_timeout_post_invoke docstring: 'control returns to the caller which raises' was stale; the caller now raises itself. - Add tests: TestMcpSseTimeoutPostInvokeFiresOnce: asserts invoke_hook fires exactly once on an unretried SSE timeout. TestMcpStreamableHttpTimeoutPostInvokeFiresOnce: same for StreamableHTTP. TestMakeToolErrorStructuredContentSurvivesCoercion: asserts structuredContent from _make_mcp_tool_error survives _coerce_to_tool_result (direct-proxy regression guard). - Update two existing SSE/StreamableHTTP timeout tests that asserted the old broken return-instead-of-raise behaviour; they now expect ToolTimeoutError to be raised. Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com>
8d7a84f to
7fd56e4
Compare
|
This branch has a merge conflict with |
|
13 unit tests fail against current `main` (post MCP SDK v2 migration, #6868):
Production code is fine (`_make_mcp_tool_error` still passes the camelCase kwargs into the constructor, which works). Please update the test mocks/assertions and confirm `make test` (or at minimum `tests/unit/mcpgateway/services/test_tool_service.py` + `test_tool_service_coverage.py`) passes locally before pushing — CI's `pytest (py3.12)` job will fail on this otherwise. |
Tests patched the removed tool_service.sse_client / tool_service.streamablehttp_client / tool_service.ClientSession symbols and asserted the camelCase structuredContent/isError attributes. Main's MCP SDK v2 migration (#6868) replaced those with a single mcp_proxy_client context manager and renamed CallToolResult's fields to structured_content/is_error. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
96db743 to
a952c3d
Compare
🚨 Blocking IssuesB1 — PR description contradicts timeout implementation
B2 —
|
|
Status of the blocking items:
Warnings W1–W6 noted; will address separately. |
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
* fix: harden outbound HTTP connection handling Outbound HTTP requests now establish connections against an address that the outbound URL policy validated, and keep the original hostname for the Host header and for TLS certificate verification. A shared helper centralises this so every outbound path applies the same policy. Outbound URL validation now runs regardless of the SSRF protection setting. The scheme allowlist, dangerous-protocol, control-character, embedded-credential and XSS checks apply unconditionally; disabling SSRF protection no longer disables them as a side effect. Behaviour notes for operators are in CHANGELOG.md, including pooled MCP sessions no longer following redirects, fail-closed handling when a caller-supplied client's TLS context cannot be read, and reduced DNS address failover on some requests. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * style: apply ruff format to the changed files CI's pre-commit job runs the ruff-format hook, which is not covered by the local 'make ruff' target (check-only). Reformats five files this branch touched. Verified with an AST comparison before/after that no file's parse tree changed except one docstring collapsed from two lines to one, whose text content is identical; every other change is line-wrap and trailing- comma only. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * style: correct file headers on two new files The repo's correct-file-headers pre-commit hook expects a Location/Copyright/ SPDX docstring header on every mcpgateway/ and tests/ Python file. ssrf_pinning.py had a stale 'Copyright 2025' line instead of the current 'Copyright contributors to the MCP-CONTEXT-FORGE project'; the new test file had no header block at all. Applied via .github/tools/fix_file_headers.py --fix-all, the same tool CI runs. No code content changed. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: restore the request URL after a pinned send Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: isolate pinned OAuth and cross-gateway requests from the shared pool httpcore keys pooled connections by (scheme, host, port) and ignores sni_hostname. Outbound DNS pinning rewrites the host to a validated IP, so two different hostnames pinned to the same address collapse to one origin and can reuse a connection whose TLS handshake verified a certificate for the other hostname, while carrying this hostname's credentials. Move the OAuth token POST (oauth_manager.py _post_token_request) and the UAID cross-gateway POST (a2a_service.py _invoke_remote_agent) off the shared HTTP client onto get_isolated_http_client, matching the pattern already used by tool_service.py and the sibling A2A local invocation path. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: isolate pinned LLM provider requests from the shared pool Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs: record that pinned requests require an isolated client Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test: adapt sibling tests to isolated-client pinning fix Four sibling test files independently exercised the outbound pinning code paths fixed earlier on this branch, but mocked the old access points or asserted the old buggy behaviour, so a full make test run still failed 56 cases across them. - test_mcp_servers_router.py: the two SNI-pinning transport tests asserted the request URL stayed pinned to the resolved IP after a successful send. That is the bug fixed in 228d2c2 (the transport restores request.url to the original hostname in a finally block). Update both assertions to expect the restored hostname. - test_a2a_service_uaid_security.py: _invoke_remote_agent's UAID path now calls get_isolated_http_client() directly instead of the shared get_http_client() singleton. Add an autouse fixture, trimmed from the one already used in test_a2a_service.py, that bridges the isolated client call to whatever get_http_client mock each test installs. - test_oauth_manager_pkce.py and test_identity_propagation.py: _post_token_request's non-mTLS branch now calls the free function get_isolated_http_client() instead of the per-instance _get_client() method, so a method override can no longer intercept it. Convert every _get_client patch/monkeypatch site to patch get_isolated_http_client via the _isolated_client() helper already reviewed in test_oauth_manager.py, adding a local copy of the helper to each file. test_identity_propagation.py's raw attribute assignments also gained the monkeypatch fixture they needed. The mTLS branch of _post_token_request builds its own inline httpx.AsyncClient and is unchanged, so no mTLS-path test was touched. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs: correct stale oauth_manager isolated-client docs, harden pinning test Update _post_token_request's docstring and the follow_redirects comment, which still described the pre-fix shared-client behavior and could mislead a future maintainer into reverting the isolated-client fix that keeps pinned token requests out of the shared connection pool. Strengthen test_oauth_token_post_uses_an_isolated_client to assert follow_redirects=False on both captured isolated clients, matching its LLM proxy sibling test. Add a regression test proving SniPinningTransport's URL-restore finally block does not corrupt or block a still-open multi-chunk response stream. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test: migrate outbound DNS pinning tests to httpx2 SniPinningTransport now subclasses httpx2.AsyncHTTPTransport and the gateway/tool-service connectors route through mcp_proxy_client instead of importing sse_client/streamablehttp_client directly. Update the transport and connector-patch tests to match. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: skip TLS fail-closed check for non-pooled transports A transport with no connection pool (an in-process ASGITransport, used by tests) never dials the network, so there is no DNS-rebinding surface for _wrap_httpx_client_factory to pin. Treat that case as unpinned instead of raising, and keep the RuntimeError fail-closed path for a pooled transport that unexpectedly carries no TLS context. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * style: apply ruff format to llm_proxy_service.py Parenthesize the multi-context-manager async with, matching the ruff-format style the pre-commit CI job enforces. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fix: keep outbound pinning when the proxy variable does not match the URL scheme httpx mounts HTTP_PROXY on http:// only and HTTPS_PROXY on https:// only. _egress_proxy_applies() treated any proxy variable as covering every scheme. With only HTTP_PROXY set, an https:// target skipped pinning while httpx still connected directly and resolved DNS again. Select the proxy variables by the URL scheme. Clarify in the module docstring that the shared-pool constraint applies to URLs pinned by PinnedTarget.pin(), not to SniPinningTransport. Add regression tests for the proxy scheme match, NO_PROXY exemptions, unsafe Host authorities, pinned-address exhaustion, the pooled-session wrap no-op paths, the streamablehttp health check, the OAuth custom-CA branch, and the RuntimeError type on a blocked pooled session. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test: assert the pinned dial address in SniPinningTransport router tests The URL-restore change dropped the check that the transport dials the pinned address. Capture the URL inside the mocked send so the tests assert both the dialled address and the restored hostname. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * docs: name invoke_tool in the SSRF_DNS_FAIL_CLOSED description Name the REST arm of tool invocation explicitly. Remove an unrelated issue reference from two session registry test docstrings. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test: expect MCP error results from tool pinning tests after rebase invoke_tool_direct and the MCP transport arms of invoke_tool now return a CallToolResult with is_error set instead of raising (IBM#6181). Assert the error result instead of an exception. The direct-proxy refusal test records dial attempts, because the error result also absorbs the AssertionError from _refuse_to_dial. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * test: configure URL schemes in DNS fail-open test Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> * fixed pre-commit Signed-off-by: Jitesh Nair <jiteshnair@ibm.com> --------- Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> Signed-off-by: Jitesh Nair <jiteshnair@ibm.com> Co-authored-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com> Co-authored-by: Jitesh Nair <jiteshnair@ibm.com>
Pull Request
Fixes MCP protocol violation where tool execution failures returned JSON-RPC errors instead of properly structured MCP
CallToolResultobjects, causingcontent: Field requiredvalidation errors in MCP clients.Background: This bug was discovered when langchain-mcp-adapters failed to parse error responses from Context Forge because the gateway returned JSON-RPC error objects instead of MCP-compliant
CallToolResultstructures with{content: [...], isError: true}.📝 Summary
Problem: The Context Forge gateway violated the MCP protocol specification by raising exceptions and returning JSON-RPC errors when MCP servers failed, timed out, or returned malformed responses. According to the MCP spec, ALL tool results must have a
contentfield and use theisErrorboolean to indicate failures.Solution: Modified all MCP execution paths to return properly structured
types.CallToolResultobjects withisError=trueinstead of raising exceptions. This ensures protocol compliance while preserving appropriate exception handling for pre-execution errors (authentication, validation, configuration).Changes Made
Direct Proxy Mode (
invoke_tool_direct):CallToolResult(content=[...], isError=True)on connection/execution failuresToolInvocationErrorSSE Transport:
ToolTimeoutErrorsoinvoke_toolcan apply the retry-plugin delay.TOOL_POST_INVOKEfires exactly once, in_run_timeout_post_invoke. Added a comment that documents this.BaseExceptionStreamableHTTP Transport:
ToolTimeoutErrorsoinvoke_toolcan apply the retry-plugin delay.TOOL_POST_INVOKEfires exactly once, in_run_timeout_post_invoke. Added a comment that documents this.BaseExceptionTest Updates: Updated 5 tests to verify structured error responses:
test_mcp_sse_timeout_triggers_post_hook_and_cb_contexttest_mcp_sse_exception_group_is_logged_and_wrappedtest_streamablehttp_timeout_triggers_post_hook_without_contexttest_streamablehttp_exception_group_is_logged_and_wrappedtest_invoke_tool_direct_connection_errorImpact
Before: MCP server failures caused
content: Field requiredvalidation errors in clients{"jsonrpc": "2.0", "error": {"code": -32000, "message": "Tool invocation failed"}, "id": 1}After: MCP server failures return properly structured responses
{ "jsonrpc": "2.0", "result": { "content": [{"type": "text", "text": "MCP server error: Connection refused"}], "isError": true }, "id": 1 }📏 Reviewability
triage(bug discovered during investigation)Scope: This PR strictly addresses MCP protocol compliance for runtime execution errors. Pre-execution errors (auth, validation, configuration) continue to raise exceptions as designed.
🏷️ Type of Change
Category: Critical bug fix for protocol compliance
🧪 Verification
Test Results
All affected tests now pass with updated assertions:
# Run specific test suite pytest tests/unit/mcpgateway/services/test_tool_service_coverage.py::TestInvokeToolMcpSseTimeoutAndErrors -xvs pytest tests/unit/mcpgateway/services/test_tool_service_coverage.py::TestInvokeToolMcpStreamableHttpCoverage -xvs pytest tests/unit/mcpgateway/services/test_tool_service.py::TestInvokeToolDirect::test_invoke_tool_direct_connection_error -xvsManual Verification Steps
Simulate MCP Server Failure:
Expected Response:
{ "jsonrpc": "2.0", "result": { "content": [{"type": "text", "text": "MCP server error: Connection refused"}], "isError": true }, "id": 1 }Verify langchain-mcp-adapters Compatibility:
content: Field requiredvalidation errorsVerification Checklist
git diff --stat mainmake ruff pylintmake testmake coveragepytest tests/unit/.../test_tool_*make pre-commit✅ Checklist
make black isort pre-commit)git commit -s)📓 Notes
Design Decisions
Runtime vs Pre-execution Errors:
session.call_tool()): ReturnCallToolResultwithisError=TrueError Message Format:
"MCP server error: ..."for clarityTransport Coverage:
MCP Protocol Reference
From the MCP specification:
Key Point: Upstream MCP server failures (connection errors,
BaseExceptionfrom the transport) return aCallToolResultwithisError: trueinside a successful JSON-RPC response. Timeouts still raiseToolTimeoutError, which keeps the existing retry path.Files Modified
mcpgateway/services/tool_service.py (+50/-7 lines):
invoke_tool_directexception handlertests/unit/mcpgateway/services/test_tool_service_coverage.py (+48/-10 lines):
tests/unit/mcpgateway/services/test_tool_service.py (+18/-6 lines):