Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,9 @@ async def get_tool(self, key: str) -> Tool:
return tool
except NotFoundError:
continue
except RuntimeError as e:
logger.warning(f"Provider unavailable when getting tool {key!r}: {e}")
continue

raise NotFoundError(f"Unknown tool: {key}")

Expand Down Expand Up @@ -754,6 +757,12 @@ async def _get_resource_or_template_or_none(
return resource
except NotFoundError:
continue
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(
f"Provider unavailable when getting resource {uri!r}: {e}"
)
continue

# Check provider templates
for provider in self._providers:
Expand All @@ -763,6 +772,12 @@ async def _get_resource_or_template_or_none(
return template
except NotFoundError:
continue
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(
f"Provider unavailable when getting resource template {uri!r}: {e}"
)
continue

return None

Expand Down Expand Up @@ -803,6 +818,12 @@ async def get_resource(self, key: str) -> Resource:
return resource
except NotFoundError:
continue
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(
f"Provider unavailable when getting resource {key!r}: {e}"
)
continue

raise NotFoundError(f"Unknown resource: {key}")

Expand Down Expand Up @@ -843,6 +864,12 @@ async def get_resource_template(self, key: str) -> ResourceTemplate:
return template
except NotFoundError:
continue
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(
f"Provider unavailable when getting resource template {key!r}: {e}"
)
continue

raise NotFoundError(f"Unknown resource template: {key}")

Expand Down Expand Up @@ -883,6 +910,10 @@ async def get_prompt(self, key: str) -> Prompt:
return prompt
except NotFoundError:
continue
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(f"Provider unavailable when getting prompt {key!r}: {e}")
continue

raise NotFoundError(f"Unknown prompt: {key}")

Expand Down Expand Up @@ -1531,6 +1562,10 @@ async def _call_tool(
except ToolError:
logger.exception(f"Error calling tool {tool_name!r}")
raise
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(f"Provider unavailable for tool {tool_name!r}: {e}")
continue
except Exception as e:
logger.exception(f"Error calling tool {tool_name!r} from provider")
if self._mask_error_details:
Expand Down Expand Up @@ -1643,6 +1678,10 @@ async def _read_resource(
except ResourceError:
logger.exception(f"Error reading resource {uri_str!r}")
raise
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(f"Provider unavailable for resource {uri_str!r}: {e}")
continue
except Exception as e:
logger.exception(f"Error reading resource {uri_str!r} from provider")
if self._mask_error_details:
Expand All @@ -1660,6 +1699,12 @@ async def _read_resource(
except ResourceError:
logger.exception(f"Error reading resource {uri_str!r}")
raise
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(
f"Provider unavailable for resource template {uri_str!r}: {e}"
)
continue
except Exception as e:
logger.exception(
f"Error reading resource {uri_str!r} from provider template"
Expand Down Expand Up @@ -1767,6 +1812,10 @@ async def _get_prompt(
except PromptError:
logger.exception(f"Error rendering prompt {name!r}")
raise
except RuntimeError as e:
# Connection failures (e.g., dead proxy) - continue to next provider
logger.warning(f"Provider unavailable for prompt {name!r}: {e}")
continue
except Exception as e:
logger.exception(f"Error rendering prompt {name!r} from provider")
if self._mask_error_details:
Expand Down
239 changes: 239 additions & 0 deletions tests/server/test_proxy_shared_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Test dead proxy with shared prefix scenarios.

These tests verify that when a dead proxy and a working server share the same
prefix (or have no prefix), the system gracefully falls back to the working
server instead of failing.
"""

from typing import cast

import httpx
from mcp.shared._httpx_utils import McpHttpClientFactory

from fastmcp import Client, FastMCP
from fastmcp.client.transports import SSETransport


def _short_timeout_client_factory(**kwargs) -> httpx.AsyncClient: # type: ignore[type-arg]
"""Create httpx client with short timeout for faster test failures."""
return httpx.AsyncClient(timeout=httpx.Timeout(1.0), **kwargs)


# Cast to satisfy type checker
_factory = cast(McpHttpClientFactory, _short_timeout_client_factory)


class TestDeadProxySharedPrefix:
"""Test graceful handling when dead proxy shares prefix with working server."""

async def test_dead_proxy_first_same_prefix_tools(self):
"""Test tool call when dead proxy is mounted FIRST with same prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.tool
def my_tool() -> str:
return "Working tool"

# Mount unreachable proxy FIRST with prefix "shared"
# Use short timeout to fail fast on Windows
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy, "shared")

# Mount working server SECOND with SAME prefix "shared"
main_app.mount(working_app, "shared")

async with Client(main_app) as client:
# List should work (errors caught)
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "shared_my_tool" in tool_names

# Call the tool - should fall back to working server
result = await client.call_tool("shared_my_tool", {})
assert result.data == "Working tool"

async def test_dead_proxy_first_no_prefix_tools(self):
"""Test tool call when dead proxy is mounted FIRST with no prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.tool
def my_tool() -> str:
return "Working tool"

# Mount unreachable proxy FIRST without prefix
# Use short timeout to fail fast on Windows
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy)

# Mount working server SECOND without prefix
main_app.mount(working_app)

async with Client(main_app) as client:
# List should work (errors caught)
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "my_tool" in tool_names

# Call the tool - should fall back to working server
result = await client.call_tool("my_tool", {})
assert result.data == "Working tool"

async def test_dead_proxy_first_same_prefix_resources(self):
"""Test resource read when dead proxy is mounted FIRST with same prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.resource(uri="data://info")
def my_resource() -> str:
return "Working resource"

# Mount unreachable proxy FIRST
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy, "shared")

# Mount working server SECOND with same prefix
main_app.mount(working_app, "shared")

async with Client(main_app) as client:
# List should work
resources = await client.list_resources()
resource_uris = [str(r.uri) for r in resources]
assert "data://shared/info" in resource_uris

# Read resource - should fall back to working server
result = await client.read_resource("data://shared/info")
assert result[0].text == "Working resource"

async def test_dead_proxy_first_same_prefix_prompts(self):
"""Test prompt render when dead proxy is mounted FIRST with same prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.prompt
def my_prompt() -> str:
return "Working prompt"

# Mount unreachable proxy FIRST
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy, "shared")

# Mount working server SECOND with same prefix
main_app.mount(working_app, "shared")

async with Client(main_app) as client:
# List should work
prompts = await client.list_prompts()
prompt_names = [p.name for p in prompts]
assert "shared_my_prompt" in prompt_names

# Get prompt - should fall back to working server
result = await client.get_prompt("shared_my_prompt")
assert result.messages[0].content.text == "Working prompt"

async def test_dead_proxy_first_no_prefix_resources(self):
"""Test resource read when dead proxy is mounted FIRST with no prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.resource(uri="data://info")
def my_resource() -> str:
return "Working resource"

# Mount unreachable proxy FIRST without prefix
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy)

# Mount working server SECOND without prefix
main_app.mount(working_app)

async with Client(main_app) as client:
# List should work
resources = await client.list_resources()
resource_uris = [str(r.uri) for r in resources]
assert "data://info" in resource_uris

# Read resource - should fall back to working server
result = await client.read_resource("data://info")
assert result[0].text == "Working resource"

async def test_dead_proxy_first_no_prefix_prompts(self):
"""Test prompt render when dead proxy is mounted FIRST with no prefix."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")

@working_app.prompt
def my_prompt() -> str:
return "Working prompt"

# Mount unreachable proxy FIRST without prefix
unreachable_client = Client(
transport=SSETransport(
"http://127.0.0.1:9999/sse/",
httpx_client_factory=_factory,
),
name="unreachable_client",
)
unreachable_proxy = FastMCP.as_proxy(
unreachable_client, name="unreachable_proxy"
)
main_app.mount(unreachable_proxy)

# Mount working server SECOND without prefix
main_app.mount(working_app)

async with Client(main_app) as client:
# List should work
prompts = await client.list_prompts()
prompt_names = [p.name for p in prompts]
assert "my_prompt" in prompt_names

# Get prompt - should fall back to working server
result = await client.get_prompt("my_prompt")
assert result.messages[0].content.text == "Working prompt"