Skip to content

Commit 97bff96

Browse files
jlowinclaude
andauthored
Revert "Forward backend capabilities in ProxyProvider (#3956)" (#3964)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e67c53 commit 97bff96

4 files changed

Lines changed: 11 additions & 258 deletions

File tree

src/fastmcp/server/mixins/lifespan.py

Lines changed: 0 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,6 @@ async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]:
175175
for provider in self.providers:
176176
await stack.enter_async_context(provider.lifespan())
177177

178-
# After providers are up, adjust MCP handlers to reflect actual
179-
# backend capabilities (removes handlers for unsupported methods).
180-
self._sync_proxy_capabilities()
181-
182178
self._started.set()
183179
try:
184180
yield
@@ -195,112 +191,6 @@ async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]:
195191
self._lifespan_result_set = False
196192
self._lifespan_result = None
197193

198-
def _sync_proxy_capabilities(self: FastMCP) -> None:
199-
"""Remove MCP handlers for capabilities the backend does not support.
200-
201-
After provider lifespans have run, any ProxyProvider instances have had a
202-
chance to preload their backend's serverCapabilities. If the backend doesn't
203-
support a capability (resources, prompts, tools) and there are no local
204-
components of that type either, we remove the corresponding request handlers
205-
from the low-level MCP server.
206-
207-
This has two effects:
208-
1. The ``initialize`` response no longer advertises unsupported capabilities.
209-
2. Clients that try to use an unsupported method receive a proper
210-
``METHOD_NOT_FOUND`` (-32601) JSON-RPC error instead of an empty list.
211-
212-
The adjustment is conservative: if there are any providers whose capabilities
213-
are not known (i.e. not a LocalProvider or ProxyProvider with loaded caps),
214-
we leave the handlers untouched.
215-
"""
216-
import mcp.types
217-
218-
from fastmcp.server.providers.local_provider.local_provider import LocalProvider
219-
from fastmcp.server.providers.proxy import ProxyProvider
220-
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
221-
222-
def _unwrap(p: Any) -> Any:
223-
"""Recursively unwrap _WrappedProvider to reach the inner provider."""
224-
while isinstance(p, _WrappedProvider):
225-
p = p._inner
226-
return p
227-
228-
# Restore handlers to the baseline that was saved at construction time
229-
# so that a server reused across multiple lifespan cycles starts clean.
230-
baseline = getattr(self._mcp_server, "_baseline_request_handlers", None)
231-
if baseline is not None:
232-
self._mcp_server.request_handlers = dict(baseline)
233-
else:
234-
self._mcp_server._baseline_request_handlers = dict( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
235-
self._mcp_server.request_handlers
236-
)
237-
238-
# Unwrap all providers so we can inspect the actual provider type,
239-
# including namespaced providers wrapped in _WrappedProvider.
240-
unwrapped = [_unwrap(p) for p in self.providers]
241-
242-
all_proxy_providers = [p for p in unwrapped if isinstance(p, ProxyProvider)]
243-
if not all_proxy_providers:
244-
return
245-
246-
# If any ProxyProvider failed to preload capabilities, we can't safely
247-
# prune: that backend's capabilities are unknown and removing handlers
248-
# could break capabilities it can actually serve.
249-
if any(p._backend_capabilities is None for p in all_proxy_providers):
250-
return
251-
252-
# Only adjust when every provider is either a LocalProvider or a
253-
# ProxyProvider with known capabilities. Unknown providers may have
254-
# components we can't inspect synchronously, so we leave things alone.
255-
if any(not isinstance(p, (LocalProvider, ProxyProvider)) for p in unwrapped):
256-
return
257-
258-
# Aggregate: a capability is "supported" if ANY proxy backend supports it.
259-
backend_caps = [
260-
p._backend_capabilities
261-
for p in all_proxy_providers
262-
if p._backend_capabilities is not None
263-
]
264-
any_resources = any(bool(c.resources) for c in backend_caps)
265-
any_prompts = any(bool(c.prompts) for c in backend_caps)
266-
any_tools = any(bool(c.tools) for c in backend_caps)
267-
268-
# Check all LocalProvider instances for statically-registered components.
269-
# A user may pass additional LocalProvider instances via the providers kwarg,
270-
# so we aggregate across every LocalProvider in self.providers, not just
271-
# the server's built-in self._local_provider.
272-
from fastmcp.prompts.base import Prompt
273-
from fastmcp.resources.base import Resource
274-
from fastmcp.resources.template import ResourceTemplate
275-
from fastmcp.tools.base import Tool
276-
277-
local_components = [
278-
c
279-
for p in unwrapped
280-
if isinstance(p, LocalProvider)
281-
for c in p._components.values()
282-
]
283-
local_has_resources = any(
284-
isinstance(c, (Resource, ResourceTemplate)) for c in local_components
285-
)
286-
local_has_prompts = any(isinstance(c, Prompt) for c in local_components)
287-
local_has_tools = any(isinstance(c, Tool) for c in local_components)
288-
289-
if not any_resources and not local_has_resources:
290-
self._mcp_server.request_handlers.pop(mcp.types.ListResourcesRequest, None)
291-
self._mcp_server.request_handlers.pop(
292-
mcp.types.ListResourceTemplatesRequest, None
293-
)
294-
self._mcp_server.request_handlers.pop(mcp.types.ReadResourceRequest, None)
295-
296-
if not any_prompts and not local_has_prompts:
297-
self._mcp_server.request_handlers.pop(mcp.types.ListPromptsRequest, None)
298-
self._mcp_server.request_handlers.pop(mcp.types.GetPromptRequest, None)
299-
300-
if not any_tools and not local_has_tools:
301-
self._mcp_server.request_handlers.pop(mcp.types.ListToolsRequest, None)
302-
self._mcp_server.request_handlers.pop(mcp.types.CallToolRequest, None)
303-
304194
def _setup_task_protocol_handlers(self: FastMCP) -> None:
305195
"""Register SEP-1686 task protocol handlers with SDK.
306196

src/fastmcp/server/providers/proxy.py

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
import base64
1111
import inspect
1212
import time
13-
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
14-
from contextlib import asynccontextmanager
13+
from collections.abc import Awaitable, Callable, Sequence
1514
from typing import TYPE_CHECKING, Any, cast
1615
from urllib.parse import quote
1716

@@ -566,7 +565,6 @@ def __init__(
566565
self._resources_cache: _CacheEntry[Resource] | None = None
567566
self._templates_cache: _CacheEntry[ResourceTemplate] | None = None
568567
self._prompts_cache: _CacheEntry[Prompt] | None = None
569-
self._backend_capabilities: mcp.types.ServerCapabilities | None = None
570568

571569
async def _get_client(self) -> Client:
572570
"""Gets a client instance by calling the sync or async factory."""
@@ -722,38 +720,6 @@ async def _get_prompt(
722720
return None
723721
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
724722

725-
# -------------------------------------------------------------------------
726-
# Lifecycle
727-
# -------------------------------------------------------------------------
728-
729-
@asynccontextmanager
730-
async def lifespan(self) -> AsyncIterator[None]:
731-
"""Preload backend capabilities at server startup.
732-
733-
Connects to the backend during lifespan to fetch its serverCapabilities
734-
from the initialize response. These are stored on the provider and used
735-
by the hosting FastMCP server to advertise accurate capabilities and to
736-
remove handlers for methods the backend does not support.
737-
"""
738-
self._backend_capabilities = None
739-
try:
740-
client = await self._get_client()
741-
async with client:
742-
init_result = client.initialize_result
743-
if init_result is not None:
744-
self._backend_capabilities = init_result.capabilities
745-
else:
746-
logger.warning(
747-
"ProxyProvider: backend did not return an initialize result; "
748-
"capabilities will not be filtered"
749-
)
750-
except Exception as e:
751-
logger.warning(
752-
f"ProxyProvider: could not preload backend capabilities: {e}; "
753-
"capabilities will not be filtered"
754-
)
755-
yield
756-
757723
# -------------------------------------------------------------------------
758724
# Task methods
759725
# -------------------------------------------------------------------------

src/fastmcp/server/providers/wrapped_provider.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ def __init__(self, inner: Provider, transform: Transform) -> None:
4949
def __repr__(self) -> str:
5050
return f"_WrappedProvider({self._inner!r}, transforms={self._transforms!r})"
5151

52-
@asynccontextmanager
53-
async def lifespan(self) -> AsyncIterator[None]:
54-
"""Delegate lifespan to the inner provider."""
55-
async with self._inner.lifespan():
56-
yield
57-
5852
# -------------------------------------------------------------------------
5953
# Delegate to inner provider's public methods (which apply inner's transforms)
6054
# -------------------------------------------------------------------------
@@ -142,3 +136,13 @@ async def get_tasks(self) -> Sequence[FastMCPComponent]:
142136
]
143137
if c.task_config.supports_tasks()
144138
]
139+
140+
# -------------------------------------------------------------------------
141+
# Lifecycle - combine with inner
142+
# -------------------------------------------------------------------------
143+
144+
@asynccontextmanager
145+
async def lifespan(self) -> AsyncIterator[None]:
146+
"""Combine lifespan with inner provider."""
147+
async with self._inner.lifespan():
148+
yield

tests/server/providers/proxy/test_proxy_server.py

Lines changed: 0 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -915,110 +915,3 @@ async def test_call_tool_through_server_uses_cache(self, fastmcp_server):
915915
result = await proxy.call_tool("greet", {"name": "Alice"})
916916
mock_list.assert_not_called()
917917
assert result.content[0].text == "Hello, Alice!" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
918-
919-
920-
def _make_tools_only_backend() -> FastMCP:
921-
"""Create a FastMCP backend that only supports tools (no resources/prompts)."""
922-
backend = FastMCP("ToolsOnlyBackend")
923-
924-
@backend.tool
925-
def my_tool() -> str:
926-
return "result"
927-
928-
# Remove resource and prompt handlers to simulate a tools-only MCP server
929-
backend._mcp_server.request_handlers.pop(mcp_types.ListResourcesRequest, None)
930-
backend._mcp_server.request_handlers.pop(
931-
mcp_types.ListResourceTemplatesRequest, None
932-
)
933-
backend._mcp_server.request_handlers.pop(mcp_types.ReadResourceRequest, None)
934-
backend._mcp_server.request_handlers.pop(mcp_types.ListPromptsRequest, None)
935-
backend._mcp_server.request_handlers.pop(mcp_types.GetPromptRequest, None)
936-
return backend
937-
938-
939-
class TestProxyCapabilityForwarding:
940-
"""Proxy advertises the backend's actual capabilities (issue #3948)."""
941-
942-
async def test_proxy_forwards_tools_only_capabilities(self):
943-
"""When backend only supports tools, proxy initialize response should not
944-
include resources or prompts capabilities."""
945-
backend = _make_tools_only_backend()
946-
proxy = create_proxy(FastMCPTransport(backend))
947-
948-
async with Client(proxy) as client:
949-
init_result = client.initialize_result
950-
assert init_result is not None
951-
caps = init_result.capabilities
952-
assert caps.tools is not None
953-
assert caps.resources is None
954-
assert caps.prompts is None
955-
956-
async def test_proxy_full_backend_advertises_all_capabilities(
957-
self, fastmcp_server: FastMCP
958-
):
959-
"""When backend supports tools, resources and prompts, proxy should
960-
advertise all three."""
961-
proxy = create_proxy(FastMCPTransport(fastmcp_server))
962-
963-
async with Client(proxy) as client:
964-
init_result = client.initialize_result
965-
assert init_result is not None
966-
caps = init_result.capabilities
967-
assert caps.tools is not None
968-
assert caps.resources is not None
969-
assert caps.prompts is not None
970-
971-
async def test_proxy_resources_list_returns_method_not_found(self):
972-
"""When backend does not support resources, resources/list on the proxy
973-
must return a METHOD_NOT_FOUND error, not an empty list."""
974-
backend = _make_tools_only_backend()
975-
proxy = create_proxy(FastMCPTransport(backend))
976-
977-
async with Client(proxy) as client:
978-
with pytest.raises(McpError) as exc_info:
979-
await client.list_resources()
980-
assert exc_info.value.error.code == mcp_types.METHOD_NOT_FOUND
981-
982-
async def test_proxy_prompts_list_returns_method_not_found(self):
983-
"""When backend does not support prompts, prompts/list on the proxy
984-
must return a METHOD_NOT_FOUND error, not an empty list."""
985-
backend = _make_tools_only_backend()
986-
proxy = create_proxy(FastMCPTransport(backend))
987-
988-
async with Client(proxy) as client:
989-
with pytest.raises(McpError) as exc_info:
990-
await client.list_prompts()
991-
assert exc_info.value.error.code == mcp_types.METHOD_NOT_FOUND
992-
993-
async def test_proxy_preserves_local_resources_even_without_backend_support(self):
994-
"""When proxy has local resources but backend doesn't, resources capability
995-
should still be advertised and local resources should be accessible."""
996-
backend = _make_tools_only_backend()
997-
proxy = create_proxy(FastMCPTransport(backend))
998-
999-
@proxy.resource("local://data")
1000-
def local_data() -> str:
1001-
return "local"
1002-
1003-
async with Client(proxy) as client:
1004-
init_result = client.initialize_result
1005-
assert init_result is not None
1006-
caps = init_result.capabilities
1007-
assert caps.tools is not None
1008-
assert caps.resources is not None # local resource present
1009-
assert caps.prompts is None # still no prompts
1010-
1011-
resources = await client.list_resources()
1012-
assert any(str(r.uri) == "local://data" for r in resources)
1013-
1014-
async def test_proxy_backend_capabilities_preloaded_in_lifespan(self):
1015-
"""ProxyProvider._backend_capabilities should be set after lifespan runs."""
1016-
backend = _make_tools_only_backend()
1017-
provider = ProxyProvider(lambda: ProxyClient(FastMCPTransport(backend)))
1018-
1019-
async with provider.lifespan():
1020-
caps = provider._backend_capabilities
1021-
assert caps is not None
1022-
assert caps.tools is not None
1023-
assert caps.resources is None
1024-
assert caps.prompts is None

0 commit comments

Comments
 (0)