File size: 14,204 Bytes
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
e3e7994
 
 
 
 
 
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
 
 
 
 
 
 
24ceec5
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
e3e7994
 
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
 
 
24ceec5
 
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
24ceec5
 
 
 
 
 
 
 
e3e7994
 
24ceec5
 
e3e7994
24ceec5
 
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
e3e7994
24ceec5
 
 
 
 
 
 
 
 
 
 
 
 
 
e3e7994
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
"""MCP (Model Context Protocol) support β€” full client implementation.

Architecture from Gemini CLI's McpClientManager:
- Manages lifecycle of multiple MCP servers
- Bridges MCP tools into the ToolRegistry as ToolBuilder instances
- Supports stdio and SSE transports
- Lazy initialization (connects on first use)

Based on the MCP Python SDK.
"""

from __future__ import annotations

import asyncio
import json
import logging
import threading
from typing import Any, Callable

from code.config import MCPServerConfig

logger = logging.getLogger(__name__)


# ── MCP Tool Bridge ────────────────────────────────────────────────────


class MCPToolBridge:
    """Wraps an MCP tool as a ToolBuilder, making it indistinguishable from built-in tools.

    This is the key insight from Gemini CLI: MCP tools use the same ToolBuilder
    interface so they get the same policy, confirmation, and scheduling treatment.
    """

    def __init__(
        self,
        server_name: str,
        tool_name: str,
        tool_schema: dict,
        call_fn: Callable,
    ):
        self.server_name = server_name
        self.tool_name = tool_name
        self._schema = tool_schema
        self._call_fn = call_fn

        # Generate MCP-prefixed name
        self.name = f"mcp_{server_name}_{tool_name}"
        self.description = tool_schema.get("description", f"MCP tool: {tool_name}")
        self.kind = "other"
        self.is_read_only = tool_schema.get("annotations", {}).get("readOnlyHint", False)

    def get_tool_definition(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "parameters": self._schema.get(
                "inputSchema", {"type": "object", "properties": {}}
            ),
            "kind": self.kind,
            "mcp_server": self.server_name,
            "mcp_tool": self.tool_name,
        }

    def call(self, arguments: dict[str, Any]) -> dict[str, Any]:
        """Execute the MCP tool call."""
        return self._call_fn(self.tool_name, arguments)


# ── MCP Client Manager ─────────────────────────────────────────────────


class MCPClientManager:
    """Manages multiple MCP server connections.

    Lifecycle: configure β†’ connect (lazy) β†’ use β†’ disconnect
    """

    def __init__(self):
        self._configs: dict[str, MCPServerConfig] = {}
        self._clients: dict[str, Any] = {}  # server_name β†’ MCP client/session
        self._tools: dict[str, MCPToolBridge] = {}
        self._connected: set[str] = set()
        self._lock = threading.Lock()

    def configure(self, configs: list[MCPServerConfig]) -> None:
        """Set MCP server configurations."""
        self._configs = {c.name: c for c in configs if c.enabled}

    def add_server(self, config: MCPServerConfig) -> None:
        """Add or update a single MCP server config."""
        self._configs[config.name] = config
        # If already connected to this server, disconnect and reconnect
        if config.name in self._connected:
            self._disconnect(config.name)

    def remove_server(self, name: str) -> None:
        """Remove an MCP server."""
        self._configs.pop(name, None)
        self._disconnect(name)

    def connect_all(self) -> dict[str, str | None]:
        """Connect to all configured MCP servers. Returns {name: error_or_None}."""
        results = {}
        for name, config in self._configs.items():
            if config.enabled:
                try:
                    error = self._connect(name, config)
                    results[name] = error
                except Exception as e:
                    results[name] = str(e)
                    logger.error("MCP connect failed for %s: %s", name, e)
        return results

    def disconnect_all(self) -> None:
        """Disconnect all MCP servers."""
        for name in list(self._connected):
            self._disconnect(name)

    def get_tools(self) -> dict[str, MCPToolBridge]:
        """Get all discovered MCP tools."""
        return dict(self._tools)

    def get_tool(self, name: str) -> MCPToolBridge | None:
        """Get a specific MCP tool by its full name (mcp_server_tool)."""
        return self._tools.get(name)

    def call_tool(
        self, server_name: str, tool_name: str, arguments: dict[str, Any]
    ) -> dict[str, Any]:
        """Call an MCP tool on a specific server."""
        client = self._clients.get(server_name)
        if not client:
            # Try lazy connect
            config = self._configs.get(server_name)
            if not config:
                return {"error": f"MCP server '{server_name}' not configured"}
            error = self._connect(server_name, config)
            if error:
                return {"error": f"Failed to connect to '{server_name}': {error}"}
            client = self._clients.get(server_name)
            if not client:
                return {"error": f"MCP server '{server_name}' connection failed"}

        try:
            if hasattr(client, "call_tool"):
                result = client.call_tool(tool_name, arguments)
                if hasattr(result, "content"):
                    # Extract text content from MCP result
                    texts = []
                    for item in result.content:
                        if hasattr(item, "text"):
                            texts.append(item.text)
                        else:
                            texts.append(str(item))
                    return {"output": "\n".join(texts)}
                return {"output": str(result)}
            return {"output": str(client)}
        except Exception as e:
            return {"error": f"MCP tool call failed: {e}"}

    def list_resources(self, server_name: str | None = None) -> list[dict]:
        """List available MCP resources."""
        resources = []
        servers = [server_name] if server_name else list(self._connected)
        for name in servers:
            client = self._clients.get(name)
            if client and hasattr(client, "list_resources"):
                try:
                    result = client.list_resources()
                    for r in result.resources if hasattr(result, "resources") else []:
                        resources.append(
                            {
                                "server": name,
                                "uri": getattr(r, "uri", str(r)),
                                "name": getattr(r, "name", ""),
                                "description": getattr(r, "description", ""),
                            }
                        )
                except Exception as e:
                    logger.warning("Failed to list resources from %s: %s", name, e)
        return resources

    def get_status(self) -> dict[str, Any]:
        """Get status of all MCP servers."""
        status = {}
        for name, config in self._configs.items():
            status[name] = {
                "enabled": config.enabled,
                "connected": name in self._connected,
                "transport": config.transport,
                "tool_count": sum(
                    1 for t in self._tools.values() if t.server_name == name
                ),
            }
        return status

    # ── Internal ───────────────────────────────────────────────────────

    def _connect(self, name: str, config: MCPServerConfig) -> str | None:
        """Connect to a single MCP server. Returns error message or None."""
        if name in self._connected:
            return None

        try:
            # Try to import MCP SDK
            try:
                from mcp import ClientSession, StdioServerParameters  # noqa: F401
                from mcp.client.stdio import stdio_client  # noqa: F401
            except ImportError:
                logger.info("MCP SDK not installed. Skipping MCP server %s", name)
                return "mcp SDK not installed (pip install mcp)"

            if config.transport == "stdio":
                server_params = StdioServerParameters(
                    command=config.command or "",
                    args=config.args,
                    env=config.env or None,
                )

                # Run async connect in a new thread with event loop
                client_session, error = self._run_async_connect(name, server_params)
                if error or not client_session:
                    return error or f"Failed to connect to {name}"

                self._clients[name] = client_session
                self._connected.add(name)

                # Discover tools
                self._discover_tools(name, client_session)
                logger.info(
                    "MCP server '%s' connected with %d tools",
                    name,
                    sum(1 for t in self._tools.values() if t.server_name == name),
                )
                return None

            elif config.transport == "sse":
                return f"SSE transport not yet supported for {name}"

            return f"Unknown transport: {config.transport}"

        except Exception as e:
            logger.error("MCP connect error for %s: %s", name, e)
            return str(e)

    def _run_async_connect(self, name: str, server_params):
        """Run async MCP connection in a sync context."""
        from mcp import ClientSession
        from mcp.client.stdio import stdio_client

        result = {"session": None, "error": None}

        async def _connect_async():
            try:
                async with stdio_client(server_params) as (read, write):
                    async with ClientSession(read, write) as session:
                        await session.initialize()
                        result["session"] = _AsyncSessionWrapper(session)
                        # Keep the context managers alive by blocking
                        await asyncio.Future()  # Block forever
            except Exception as e:
                result["error"] = str(e)

        loop = asyncio.new_event_loop()
        thread = threading.Thread(
            target=loop.run_until_complete, args=(_connect_async(),), daemon=True
        )
        thread.start()
        thread.join(timeout=10)

        if result["error"] and not result["session"]:
            return None, result["error"]
        if result["session"]:
            return result["session"], None
        return None, "Connection timeout"

    def _discover_tools(self, server_name: str, client) -> None:
        """Discover and register tools from an MCP server."""
        # Remove old tools for this server
        old_prefix = f"mcp_{server_name}_"
        for k in list(self._tools.keys()):
            if k.startswith(old_prefix):
                del self._tools[k]

        # Discover new tools (sync wrapper)
        try:
            tools_result = client.list_tools() if hasattr(client, "list_tools") else None
            tools = tools_result.tools if hasattr(tools_result, "tools") else []

            for tool in tools:
                schema = {
                    "description": getattr(tool, "description", ""),
                    "inputSchema": getattr(
                        tool, "inputSchema", {"type": "object", "properties": {}}
                    ),
                    "annotations": getattr(tool, "annotations", {}),
                }
                tool_name = getattr(tool, "name", "unknown")

                bridge = MCPToolBridge(
                    server_name=server_name,
                    tool_name=tool_name,
                    tool_schema=schema,
                    call_fn=lambda sn=server_name, tn=tool_name, args=None:
                        self.call_tool(sn, tn, args or {}),
                )
                self._tools[bridge.name] = bridge
                logger.debug("Discovered MCP tool: %s", bridge.name)

        except Exception as e:
            logger.warning("Failed to discover tools from %s: %s", server_name, e)

    def _disconnect(self, name: str) -> None:
        """Disconnect from an MCP server."""
        self._clients.pop(name, None)
        self._connected.discard(name)
        # Remove tools
        prefix = f"mcp_{name}_"
        for k in list(self._tools.keys()):
            if k.startswith(prefix):
                del self._tools[k]


class _AsyncSessionWrapper:
    """Wraps an MCP ClientSession for sync access."""

    def __init__(self, session):
        self._session = session

    def __getattr__(self, name):
        attr = getattr(self._session, name)
        if asyncio.iscoroutinefunction(attr):

            def sync_wrapper(*args, **kwargs):
                try:
                    loop = asyncio.get_event_loop()
                    if loop.is_running():
                        # Can't await in running loop, return a placeholder
                        import concurrent.futures

                        with concurrent.futures.ThreadPoolExecutor() as pool:
                            future = pool.submit(asyncio.run, attr(*args, **kwargs))
                            return future.result(timeout=30)
                    else:
                        return loop.run_until_complete(attr(*args, **kwargs))
                except RuntimeError:
                    return asyncio.run(attr(*args, **kwargs))

            return sync_wrapper
        return attr


# ── Global Manager ─────────────────────────────────────────────────────

_global_manager: MCPClientManager | None = None


def get_mcp_manager() -> MCPClientManager:
    """Get or create the global MCP client manager."""
    global _global_manager
    if _global_manager is None:
        _global_manager = MCPClientManager()
    return _global_manager


__all__ = ["MCPToolBridge", "MCPClientManager", "get_mcp_manager"]