Owns the background loop, the connected clients, and the wrapped tools.
| 52 | |
| 53 | |
| 54 | class McpRuntime: |
| 55 | """Owns the background loop, the connected clients, and the wrapped tools.""" |
| 56 | |
| 57 | def __init__(self) -> None: |
| 58 | self._loop: asyncio.AbstractEventLoop | None = None |
| 59 | self._thread: threading.Thread | None = None |
| 60 | self.clients: dict[str, Any] = {} |
| 61 | self.servers: dict[str, list[str]] = {} # server name -> tool names |
| 62 | self.tools: list[Any] = [] # wrapped sync Tool objects (mcp__server__tool) |
| 63 | |
| 64 | def _run(self, coro: Any, timeout: float) -> Any: |
| 65 | assert self._loop is not None |
| 66 | return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout) |
| 67 | |
| 68 | def start(self) -> bool: |
| 69 | """Connect all enabled, configured MCP servers. Returns True if at least |
| 70 | one tool was registered. Fully guarded: any failure leaves the runtime |
| 71 | empty and never raises.""" |
| 72 | try: |
| 73 | from src.services.mcp.config import get_all_mcp_configs |
| 74 | except Exception: # noqa: BLE001 |
| 75 | logger.debug("[mcp] config module unavailable", exc_info=True) |
| 76 | return False |
| 77 | try: |
| 78 | configs = get_all_mcp_configs() |
| 79 | except Exception: # noqa: BLE001 |
| 80 | logger.debug("[mcp] reading configs failed", exc_info=True) |
| 81 | return False |
| 82 | enabled = { |
| 83 | name: scoped |
| 84 | for name, scoped in (configs or {}).items() |
| 85 | if getattr(getattr(scoped, "config", None), "enabled", True) |
| 86 | } |
| 87 | if not enabled: |
| 88 | return False |
| 89 | |
| 90 | self._loop = asyncio.new_event_loop() |
| 91 | self._thread = threading.Thread( |
| 92 | target=self._loop.run_forever, daemon=True, name="mcp-loop" |
| 93 | ) |
| 94 | self._thread.start() |
| 95 | |
| 96 | from src.services.mcp.client import McpClient |
| 97 | |
| 98 | for name, scoped in enabled.items(): |
| 99 | try: |
| 100 | client = McpClient() |
| 101 | self._run(client.connect(name, scoped), _CONNECT_TIMEOUT_S) |
| 102 | mcp_tools = self._run(client.list_tools(), _CONNECT_TIMEOUT_S) |
| 103 | self.clients[name] = client |
| 104 | self.servers[name] = [t.name for t in mcp_tools] |
| 105 | for mt in mcp_tools: |
| 106 | self.tools.append(self._wrap(name, mt, client)) |
| 107 | logger.info("[mcp] connected %s (%d tools)", name, len(mcp_tools)) |
| 108 | except Exception: # noqa: BLE001 — one bad server must not sink the rest |
| 109 | logger.exception("[mcp] connect failed: %s", name) |
| 110 | |
| 111 | if not self.tools: |
no outgoing calls