proxy to timeout
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, TypeVar
|
||||
@@ -16,6 +18,8 @@ from ..shared.names import ProxyNamespace
|
||||
|
||||
ProxyT = TypeVar("ProxyT")
|
||||
ProxyMountFactory = Callable[[ConnectionConfig, Path], "ProxyMount[ProxyT]"]
|
||||
_PROXY_LIST_TIMEOUT_SECONDS = 8.0
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -102,9 +106,10 @@ def create_proxy_mount(
|
||||
)
|
||||
transport = MCPConfigTransport(server_config, name_as_prefix=False)
|
||||
client = StatefulProxyClient(transport=transport, name=f"wf-mcp:{connection.id}")
|
||||
proxy: FastMCP[Any] = FastMCPProxy(
|
||||
proxy: FastMCP[Any] = ResilientFastMCPProxy(
|
||||
client_factory=client.new_stateful,
|
||||
name=f"Proxy-{connection.id}",
|
||||
connection_id=connection.id,
|
||||
)
|
||||
proxy.add_transform(ProxyNamespace(connection.id))
|
||||
proxy.add_transform(ResourceLinkNamespace(connection.id))
|
||||
@@ -113,3 +118,73 @@ def create_proxy_mount(
|
||||
fingerprint=connection_fingerprint(connection),
|
||||
proxy=proxy,
|
||||
)
|
||||
|
||||
|
||||
class ResilientFastMCPProxy(FastMCPProxy):
|
||||
"""FastMCP proxy that keeps discovery/listing best-effort per source.
|
||||
|
||||
FastMCP's aggregate provider skips providers that raise, but it still waits
|
||||
for each mounted provider to finish listing. A dead stdio server can
|
||||
therefore make top-level `tools/list` look broken for the whole broker. This
|
||||
wrapper bounds list operations only; actual calls still use FastMCPProxy's
|
||||
normal behavior and surface source failures. Timeout cancellation is also
|
||||
the cleanup signal for FastMCP/MCP's stdio transport owner; this layer does
|
||||
not launch or reap subprocesses directly.
|
||||
"""
|
||||
|
||||
def __init__(self, *, connection_id: str, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._wf_mcp_connection_id = connection_id
|
||||
|
||||
async def list_tools(self, *, run_middleware: bool = True) -> Any:
|
||||
return await _bounded_proxy_list(
|
||||
super().list_tools(run_middleware=run_middleware),
|
||||
connection_id=self._wf_mcp_connection_id,
|
||||
operation="tools/list",
|
||||
)
|
||||
|
||||
async def list_resources(self, *, run_middleware: bool = True) -> Any:
|
||||
return await _bounded_proxy_list(
|
||||
super().list_resources(run_middleware=run_middleware),
|
||||
connection_id=self._wf_mcp_connection_id,
|
||||
operation="resources/list",
|
||||
)
|
||||
|
||||
async def list_resource_templates(self, *, run_middleware: bool = True) -> Any:
|
||||
return await _bounded_proxy_list(
|
||||
super().list_resource_templates(run_middleware=run_middleware),
|
||||
connection_id=self._wf_mcp_connection_id,
|
||||
operation="resources/templates/list",
|
||||
)
|
||||
|
||||
async def list_prompts(self, *, run_middleware: bool = True) -> Any:
|
||||
return await _bounded_proxy_list(
|
||||
super().list_prompts(run_middleware=run_middleware),
|
||||
connection_id=self._wf_mcp_connection_id,
|
||||
operation="prompts/list",
|
||||
)
|
||||
|
||||
|
||||
async def _bounded_proxy_list(
|
||||
listing: Coroutine[Any, Any, Any],
|
||||
*,
|
||||
connection_id: str,
|
||||
operation: str,
|
||||
timeout_seconds: float = _PROXY_LIST_TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
"""Return proxy list results or empty list for source/transport failures.
|
||||
|
||||
Only timeout and transport-ish failures are swallowed. Programming errors
|
||||
should still escape to FastMCP's aggregate provider, which logs and skips the
|
||||
mounted provider without hiding the bug from local tests.
|
||||
"""
|
||||
try:
|
||||
return await asyncio.wait_for(listing, timeout=timeout_seconds)
|
||||
except (TimeoutError, OSError, ConnectionError) as exc:
|
||||
logger.warning(
|
||||
"Skipping %s for connection %s after listing failure: %s",
|
||||
operation,
|
||||
connection_id,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -12,6 +12,7 @@ from wf_mcp.broker import load_broker_config
|
||||
from wf_mcp.events import EventBus, InMemoryEventSink
|
||||
from wf_mcp.models import BrokerConfig, ConnectionConfig
|
||||
from wf_mcp.proxy import ProxyRuntime, create_proxy_client
|
||||
from wf_mcp.proxy.mounts import _bounded_proxy_list
|
||||
from wf_mcp.proxy.reload_events import (
|
||||
ProxyReloadResult,
|
||||
reload_change_events,
|
||||
@@ -108,6 +109,39 @@ def test_proxy_lists_and_calls_upstream_tools() -> None:
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
|
||||
def test_proxy_listing_degrades_when_one_source_hangs() -> None:
|
||||
async def stuck_listing() -> list[Any]:
|
||||
await asyncio.sleep(1)
|
||||
return [{"name": "unreachable"}]
|
||||
|
||||
async def run_timeout() -> None:
|
||||
result = await _bounded_proxy_list(
|
||||
stuck_listing(),
|
||||
connection_id="serena.default",
|
||||
operation="tools/list",
|
||||
timeout_seconds=0.01,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
asyncio.run(run_timeout())
|
||||
|
||||
|
||||
def test_proxy_listing_degrades_when_one_source_has_transport_error() -> None:
|
||||
async def broken_listing() -> list[Any]:
|
||||
raise OSError("stdio process exited")
|
||||
|
||||
async def run_failure() -> None:
|
||||
result = await _bounded_proxy_list(
|
||||
broken_listing(),
|
||||
connection_id="serena.default",
|
||||
operation="tools/list",
|
||||
timeout_seconds=1,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
asyncio.run(run_failure())
|
||||
|
||||
|
||||
def test_proxy_registers_admin_tools_on_local_provider() -> None:
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root() / "proxy_local_admin_store",
|
||||
|
||||
+7
-7
@@ -8,8 +8,8 @@
|
||||
"enabled": true,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": ["@upstash/context7-mcp"],
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -20,8 +20,8 @@
|
||||
"enabled": true,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": ["@playwright/mcp@latest"],
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -32,8 +32,8 @@
|
||||
"enabled": true,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "pnpx",
|
||||
"args": ["@modelcontextprotocol/server-everything"],
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
@@ -41,7 +41,7 @@
|
||||
"id": "serena.default",
|
||||
"server": "serena",
|
||||
"account": "default",
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"metadata": {
|
||||
"transport": "stdio",
|
||||
"command": "serena",
|
||||
|
||||
Reference in New Issue
Block a user