proxy to timeout

This commit is contained in:
lda
2026-05-28 23:28:37 +07:00 Verified
parent 4bad1c4e2c
commit f269e3b287
3 changed files with 118 additions and 9 deletions
+77 -2
View File
@@ -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 []
+34
View File
@@ -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
View File
@@ -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",