fix(middleware): report an unresolved tool lookup instead of a read only denial - #389
Om-singhaI wants to merge 5 commits into
Conversation
…nly denial In read only mode the tool filtering middleware resolves the requested tool through the FastMCP server before allowing a tools/call request. On the proxy that lookup is served by the upstream MCP endpoint, and under the default provider error strategy of fastmcp a failed upstream request (for example an HTTP 429) is swallowed and the lookup returns None. The middleware treated a None lookup and a resolved tool without readOnlyHint as the same condition and raised the same error, so a throttled lookup of a tool that is annotated read only was reported to the caller as "not available in read only mode". Agents reading that message stop retrying and abandon the run even though a short back off would have succeeded. Raise a distinct error when the lookup returns None, stating that the tool could not be resolved from the upstream server and that its read only hint could not be verified, and keep the read only message only for a tool that was resolved and lacks the readOnlyHint annotation. An exception raised by the lookup itself still propagates unchanged. Fixes aws#387
|
There are two ways to fix this and I'd rather the call got made before it goes further. The narrow one is what's here: when @arnewouters this lands on you because your review on #350 asked for the annotation to be checked on the tool at call time rather than kept in a list, and that live lookup is what the 429 in #387 breaks. No workflow has run on this yet and #395 is the same, so I think it needs someone with write access to release it. Branch is current with main. |
|
Could someone take a look at this when you get a chance? I'm happy to rework it if the approach isn't right. |
fix(middleware): report an unresolved tool lookup instead of a read only denial
Fixes #387
Summary
Changes
ToolFilteringMiddleware.on_call_toolinmcp_proxy_for_aws/middleware/tool_filter.pyresolves the requested tool withfastmcp.get_tool()before allowing atools/callrequest in read only mode. On the proxy that lookup is served by the upstream MCP endpoint. With fastmcp's defaultprovider_error_strategy="warn",AggregateProvider._get_highest_version_resultlogs and swallows any nonNotFoundErrorexception raised byProxyProvider._get_tool(which refreshes its list cache throughclient.list_tools()), so an upstream HTTP 429 makesget_tool()returnNone. The middleware collapsed "the lookup returnedNone" and "the tool resolved but has noreadOnlyHint" into one error, so a throttled lookup of a tool that is annotatedreadOnlyHint=Truewas reported asTool 'X' is not available in read-only mode., and the outerToolErrorMiddlewareforwarded that text to the caller.This change separates the two conditions:
get_tool()returnsNone, the middleware logs a warning and raisesToolError("Tool 'X' could not be resolved from the upstream server, so its read-only hint could not be verified."). ThroughToolErrorMiddlewarethe caller now seesTool call 'X' failed: Tool 'X' could not be resolved from the upstream server, so its read-only hint could not be verified.. Please retry.readOnlyHint, the existingTool 'X' is not available in read-only mode.message is kept unchanged.No server wiring changes. The larger alternative, constructing
FastMCPProxywithprovider_error_strategy="raise"so the upstream 429 propagates as its ownHTTPStatusErrorinstead of aNonelookup, would change error behaviour for every aggregate operation on the proxy and is better decided by the maintainers, so it is not part of this change. I also did not add caching ofreadOnlyHintvalues seen inon_list_tools, since #350 deliberately moved away from caching ("check tool annotations directly on call instead of caching").Out of scope: the secondary report in #387 about 41 failures rendering as
Error calling tool 'read_k8s_resource': .with an empty message. That text is produced below this middleware (the proxied tool call itself), not by the read only check, and is not addressed here.User experience
Before: in a
--read-onlysession, once the upstream endpoint starts returning 429, everytools/callis refused withTool 'X' is not available in read-only mode.even for tools annotatedreadOnlyHint=Truethat had just returned data. Agents read that as the toolset being withdrawn and stop retrying.After: the same situation is reported as
Tool 'X' could not be resolved from the upstream server, so its read-only hint could not be verified.(wrapped byToolErrorMiddlewarewith the usualPlease retry.suffix), so the caller can back off and retry. Tools that actually lackreadOnlyHintstill get the read only message.I reproduced both messages end to end against a real
FastMCPProxy(fastmcp 3.4.7, the version inuv.lock) withToolErrorMiddlewareandToolFilteringMiddleware(read_only=True)installed in the same order asserver.py, a backend tool annotatedreadOnlyHint=True, and a client whoselist_toolsraiseshttpx.HTTPStatusError429 after the first successful call with the provider list cache expired:On main:
With this change:
Checklist
Is this a breaking change? (Y/N)
Please add details about how this change was tested.
Unit tests in
tests/unit/test_tool_filter.py:test_read_only_true_rejects_unresolved_tool_without_read_only_message(replacestest_read_only_true_rejects_unknown_tool, which asserted the old collapsed message for aNonelookup): aNonelookup raisesToolErrorcontainingcould not be resolvedand not containingnot available in read-only mode, andcall_nextis not called. Fails on main, passes with the change.test_read_only_true_propagates_lookup_error(new): an exception raised byget_toolpropagates unchanged andcall_nextis not called.test_read_only_true_rejects_write_tool(existing, one assertion added): a resolved tool withreadOnlyHint=Falsestill gets the read only message and not the new one.test_read_only_true_allows_read_only_tool(existing, unchanged): a resolved tool withreadOnlyHint=Truepasses through.Results with Python 3.10.6, fastmcp 3.4.7, pytest 9.1.1, pytest asyncio 1.4.0:
tests/unit/test_tool_filter.pyon main: 18 passed.tests/unit/test_tool_filter.pywith the new tests but the source change reverted: 1 failed (test_read_only_true_rejects_unresolved_tool_without_read_only_message), 18 passed.tests/unit/test_tool_filter.pywith the change: 19 passed.tests/unitwith the change: 261 passed, 2 failed. The two failures,tests/unit/test_server.py::TestFix2EnvVarPrecedence::test_env_var_profiles_wins_over_aws_profileandtests/unit/test_server.py::TestEnvVarActivatesMiddleware::test_env_var_activates_profile_middleware, fail identically on pristine main in this environment. They are async tests decorated withunittest.mock.patch.dict; on the local Python 3.10.6 interpreter that decorator returns a non coroutine wrapper (inspect.iscoroutinefunctionis False), so pytest asyncio reports "async def functions are not natively supported". This is unrelated to the files touched here.ruff checkandruff format --checkpass on both changed files.pyrightwas not run locally (not installed in this environment).Acknowledgment
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.