Skip to content

Commit 98f69bd

Browse files
jlowinclaude
andauthored
Forward backend capabilities in ProxyProvider (#3956)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3f845f commit 98f69bd

4 files changed

Lines changed: 258 additions & 11 deletions

File tree

src/fastmcp/server/mixins/lifespan.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ 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+
178182
self._started.set()
179183
try:
180184
yield
@@ -191,6 +195,112 @@ async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]:
191195
self._lifespan_result_set = False
192196
self._lifespan_result = None
193197

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+
194304
def _setup_task_protocol_handlers(self: FastMCP) -> None:
195305
"""Register SEP-1686 task protocol handlers with SDK.
196306

src/fastmcp/server/providers/proxy.py

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

@@ -565,6 +566,7 @@ def __init__(
565566
self._resources_cache: _CacheEntry[Resource] | None = None
566567
self._templates_cache: _CacheEntry[ResourceTemplate] | None = None
567568
self._prompts_cache: _CacheEntry[Prompt] | None = None
569+
self._backend_capabilities: mcp.types.ServerCapabilities | None = None
568570

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

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+
723757
# -------------------------------------------------------------------------
724758
# Task methods
725759
# -------------------------------------------------------------------------

src/fastmcp/server/providers/wrapped_provider.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ 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+
5258
# -------------------------------------------------------------------------
5359
# Delegate to inner provider's public methods (which apply inner's transforms)
5460
# -------------------------------------------------------------------------
@@ -136,13 +142,3 @@ async def get_tasks(self) -> Sequence[FastMCPComponent]:
136142
]
137143
if c.task_config.supports_tasks()
138144
]
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: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,3 +915,110 @@ 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)