|
| 1 | +import json |
1 | 2 | import logging |
2 | 3 | import os |
3 | 4 | from typing import List, Optional |
|
12 | 13 | StreamableHTTPConnectionParams, |
13 | 14 | ) |
14 | 15 |
|
| 16 | +from .gemini_settings import load_custom_mcps |
| 17 | + |
15 | 18 | logger = logging.getLogger(__name__) |
16 | 19 |
|
17 | 20 |
|
@@ -43,6 +46,83 @@ async def close(self) -> None: |
43 | 46 | pass |
44 | 47 |
|
45 | 48 |
|
| 49 | +def _make_toolset(name: str, spec: dict) -> ResilientMcpToolset | None: |
| 50 | + """Create a ResilientMcpToolset from a custom MCP server spec. |
| 51 | +
|
| 52 | + Supports two formats (matching the Gemini CLI settings schema): |
| 53 | + - HTTP: ``{"httpUrl": "...", "headers": {...}}`` |
| 54 | + - Stdio: ``{"command": "...", "args": [...], "env": {...}}`` |
| 55 | + """ |
| 56 | + if "httpUrl" in spec: |
| 57 | + params = StreamableHTTPConnectionParams( |
| 58 | + url=spec["httpUrl"], |
| 59 | + headers=spec.get("headers", {}), |
| 60 | + timeout=spec.get("timeout", 120), |
| 61 | + ) |
| 62 | + elif "command" in spec: |
| 63 | + params = StdioConnectionParams( |
| 64 | + server_params=StdioServerParameters( |
| 65 | + command=spec["command"], |
| 66 | + args=spec.get("args", []), |
| 67 | + env=spec.get("env"), |
| 68 | + ), |
| 69 | + timeout=float(spec.get("timeout", 120)), |
| 70 | + ) |
| 71 | + else: |
| 72 | + logger.warning("Skipping custom MCP %r: no 'command' or 'httpUrl' key", name) |
| 73 | + return None |
| 74 | + return ResilientMcpToolset(McpToolset(connection_params=params), label=name) |
| 75 | + |
| 76 | + |
| 77 | +class DynamicCustomMcpToolset(BaseToolset): |
| 78 | + """Reads ``user_config/custom_mcps.json`` on every agent turn and |
| 79 | + lazily creates / caches MCP connections for each entry. |
| 80 | +
|
| 81 | + When the config file changes between turns the stale connections are |
| 82 | + torn down and new ones are established automatically — no server |
| 83 | + restart required. |
| 84 | + """ |
| 85 | + |
| 86 | + def __init__(self) -> None: |
| 87 | + super().__init__() |
| 88 | + self._toolsets: dict[str, ResilientMcpToolset] = {} |
| 89 | + self._config_hash: str | None = None |
| 90 | + |
| 91 | + async def get_tools( |
| 92 | + self, readonly_context: Optional[ReadonlyContext] = None |
| 93 | + ) -> List[BaseTool]: |
| 94 | + config = load_custom_mcps() |
| 95 | + new_hash = json.dumps(config, sort_keys=True) |
| 96 | + |
| 97 | + if new_hash != self._config_hash: |
| 98 | + await self._rebuild(config) |
| 99 | + self._config_hash = new_hash |
| 100 | + |
| 101 | + all_tools: List[BaseTool] = [] |
| 102 | + for ts in self._toolsets.values(): |
| 103 | + all_tools.extend(await ts.get_tools(readonly_context)) |
| 104 | + return all_tools |
| 105 | + |
| 106 | + async def _rebuild(self, config: dict) -> None: |
| 107 | + for ts in self._toolsets.values(): |
| 108 | + await ts.close() |
| 109 | + self._toolsets.clear() |
| 110 | + |
| 111 | + for name, spec in config.items(): |
| 112 | + ts = _make_toolset(name, spec) |
| 113 | + if ts is not None: |
| 114 | + self._toolsets[name] = ts |
| 115 | + |
| 116 | + async def close(self) -> None: |
| 117 | + for ts in self._toolsets.values(): |
| 118 | + await ts.close() |
| 119 | + self._toolsets.clear() |
| 120 | + |
| 121 | + |
| 122 | +# --------------------------------------------------------------------------- |
| 123 | +# Built-in MCP servers |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | + |
46 | 126 | all_mcps: list[BaseToolset] = [] |
47 | 127 |
|
48 | 128 | if os.getenv("PARALLEL_API_KEY"): |
@@ -71,3 +151,6 @@ async def close(self) -> None: |
71 | 151 | label="Docling MCP", |
72 | 152 | ) |
73 | 153 | all_mcps.append(docling_mcp) |
| 154 | + |
| 155 | +# User-configured custom MCP servers (loaded dynamically per-request) |
| 156 | +all_mcps.append(DynamicCustomMcpToolset()) |
0 commit comments