copex 0.8.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
copex/mcp.py ADDED
@@ -0,0 +1,561 @@
1
+ """
2
+ MCP (Model Context Protocol) Integration for Copex.
3
+
4
+ Enables:
5
+ - Connecting to external MCP servers
6
+ - Exposing tools from MCP servers to Copex
7
+ - Running a Copex MCP server for other clients
8
+
9
+ Based on the MCP specification for tool/resource sharing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Awaitable, Callable
20
+
21
+
22
+ @dataclass
23
+ class MCPTool:
24
+ """Definition of an MCP tool."""
25
+
26
+ name: str
27
+ description: str
28
+ input_schema: dict[str, Any]
29
+ handler: Callable[..., Awaitable[Any]] | None = None
30
+
31
+
32
+ @dataclass
33
+ class MCPResource:
34
+ """Definition of an MCP resource."""
35
+
36
+ uri: str
37
+ name: str
38
+ description: str = ""
39
+ mime_type: str = "text/plain"
40
+
41
+
42
+ @dataclass
43
+ class MCPServerConfig:
44
+ """Configuration for an MCP server connection."""
45
+
46
+ name: str
47
+ command: str | list[str] # Command to launch server
48
+ args: list[str] = field(default_factory=list)
49
+ env: dict[str, str] = field(default_factory=dict)
50
+ cwd: str | None = None
51
+
52
+ # Connection settings
53
+ transport: str = "stdio" # "stdio" or "http"
54
+ url: str | None = None # For HTTP transport
55
+
56
+ # Behavior
57
+ auto_start: bool = True
58
+ restart_on_crash: bool = True
59
+
60
+
61
+ class MCPTransport(ABC):
62
+ """Abstract base for MCP transport."""
63
+
64
+ @abstractmethod
65
+ async def connect(self) -> None:
66
+ """Establish connection."""
67
+ pass
68
+
69
+ @abstractmethod
70
+ async def disconnect(self) -> None:
71
+ """Close connection."""
72
+ pass
73
+
74
+ @abstractmethod
75
+ async def send(self, message: dict[str, Any]) -> dict[str, Any]:
76
+ """Send a JSON-RPC message and get response."""
77
+ pass
78
+
79
+ @abstractmethod
80
+ async def receive(self) -> dict[str, Any]:
81
+ """Receive a message."""
82
+ pass
83
+
84
+
85
+ class StdioTransport(MCPTransport):
86
+ """MCP transport over stdio (subprocess)."""
87
+
88
+ def __init__(self, config: MCPServerConfig):
89
+ self.config = config
90
+ self._process: asyncio.subprocess.Process | None = None
91
+ self._request_id = 0
92
+ self._pending: dict[int, asyncio.Future] = {}
93
+ self._reader_task: asyncio.Task | None = None
94
+
95
+ async def connect(self) -> None:
96
+ """Start the MCP server process."""
97
+ cmd = self.config.command
98
+ if isinstance(cmd, str):
99
+ cmd = [cmd] + self.config.args
100
+ else:
101
+ cmd = list(cmd) + self.config.args
102
+
103
+ self._process = await asyncio.create_subprocess_exec(
104
+ *cmd,
105
+ stdin=asyncio.subprocess.PIPE,
106
+ stdout=asyncio.subprocess.PIPE,
107
+ stderr=asyncio.subprocess.PIPE,
108
+ cwd=self.config.cwd,
109
+ env={**dict(__import__("os").environ), **self.config.env} if self.config.env else None,
110
+ )
111
+
112
+ # Start reader task
113
+ self._reader_task = asyncio.create_task(self._reader_loop())
114
+
115
+ # Initialize connection
116
+ await self._initialize()
117
+
118
+ async def _initialize(self) -> None:
119
+ """Send MCP initialization."""
120
+ await self.send({
121
+ "jsonrpc": "2.0",
122
+ "method": "initialize",
123
+ "params": {
124
+ "protocolVersion": "2024-11-05",
125
+ "capabilities": {
126
+ "tools": {},
127
+ "resources": {},
128
+ },
129
+ "clientInfo": {
130
+ "name": "copex",
131
+ "version": "0.1.0",
132
+ },
133
+ },
134
+ })
135
+
136
+ # Send initialized notification
137
+ await self._write({
138
+ "jsonrpc": "2.0",
139
+ "method": "notifications/initialized",
140
+ })
141
+
142
+ async def _reader_loop(self) -> None:
143
+ """Read responses from the server."""
144
+ if not self._process or not self._process.stdout:
145
+ return
146
+
147
+ while True:
148
+ try:
149
+ line = await self._process.stdout.readline()
150
+ if not line:
151
+ break
152
+
153
+ text = line.decode("utf-8").strip()
154
+ if not text:
155
+ continue
156
+
157
+ try:
158
+ message = json.loads(text)
159
+ except json.JSONDecodeError:
160
+ continue
161
+
162
+ # Handle response
163
+ if "id" in message:
164
+ request_id = message["id"]
165
+ if request_id in self._pending:
166
+ future = self._pending.pop(request_id)
167
+ if "error" in message:
168
+ future.set_exception(
169
+ RuntimeError(message["error"].get("message", "Unknown error"))
170
+ )
171
+ else:
172
+ future.set_result(message.get("result"))
173
+
174
+ except asyncio.CancelledError:
175
+ break
176
+ except Exception:
177
+ continue
178
+
179
+ async def _write(self, message: dict[str, Any]) -> None:
180
+ """Write a message to the server."""
181
+ if not self._process or not self._process.stdin:
182
+ raise RuntimeError("Not connected")
183
+
184
+ data = json.dumps(message) + "\n"
185
+ self._process.stdin.write(data.encode("utf-8"))
186
+ await self._process.stdin.drain()
187
+
188
+ async def send(self, message: dict[str, Any]) -> dict[str, Any]:
189
+ """Send a request and wait for response."""
190
+ self._request_id += 1
191
+ request_id = self._request_id
192
+
193
+ message = {**message, "id": request_id}
194
+
195
+ future: asyncio.Future = asyncio.get_event_loop().create_future()
196
+ self._pending[request_id] = future
197
+
198
+ await self._write(message)
199
+
200
+ try:
201
+ result = await asyncio.wait_for(future, timeout=30.0)
202
+ return result
203
+ except asyncio.TimeoutError:
204
+ self._pending.pop(request_id, None)
205
+ raise
206
+
207
+ async def receive(self) -> dict[str, Any]:
208
+ """Receive is handled by reader loop."""
209
+ raise NotImplementedError("Use send() for request/response")
210
+
211
+ async def disconnect(self) -> None:
212
+ """Stop the server process."""
213
+ if self._reader_task:
214
+ self._reader_task.cancel()
215
+ try:
216
+ await self._reader_task
217
+ except asyncio.CancelledError:
218
+ pass
219
+
220
+ if self._process:
221
+ self._process.terminate()
222
+ try:
223
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
224
+ except asyncio.TimeoutError:
225
+ self._process.kill()
226
+ self._process = None
227
+
228
+
229
+ class MCPClient:
230
+ """
231
+ Client for connecting to MCP servers.
232
+
233
+ Usage:
234
+ config = MCPServerConfig(
235
+ name="my-server",
236
+ command="npx",
237
+ args=["-y", "@my/mcp-server"],
238
+ )
239
+
240
+ client = MCPClient(config)
241
+ await client.connect()
242
+
243
+ # List available tools
244
+ tools = await client.list_tools()
245
+
246
+ # Call a tool
247
+ result = await client.call_tool("search", {"query": "hello"})
248
+
249
+ await client.disconnect()
250
+ """
251
+
252
+ def __init__(self, config: MCPServerConfig):
253
+ self.config = config
254
+ self._transport: MCPTransport | None = None
255
+ self._tools: list[MCPTool] = []
256
+ self._resources: list[MCPResource] = []
257
+
258
+ @property
259
+ def connected(self) -> bool:
260
+ """Check if connected."""
261
+ return self._transport is not None
262
+
263
+ async def connect(self) -> None:
264
+ """Connect to the MCP server."""
265
+ if self.config.transport == "stdio":
266
+ self._transport = StdioTransport(self.config)
267
+ else:
268
+ raise ValueError(f"Unsupported transport: {self.config.transport}")
269
+
270
+ await self._transport.connect()
271
+
272
+ # Fetch available tools and resources
273
+ await self._refresh_capabilities()
274
+
275
+ async def _refresh_capabilities(self) -> None:
276
+ """Refresh list of tools and resources."""
277
+ if not self._transport:
278
+ return
279
+
280
+ # Get tools
281
+ try:
282
+ result = await self._transport.send({
283
+ "jsonrpc": "2.0",
284
+ "method": "tools/list",
285
+ "params": {},
286
+ })
287
+ self._tools = [
288
+ MCPTool(
289
+ name=t["name"],
290
+ description=t.get("description", ""),
291
+ input_schema=t.get("inputSchema", {}),
292
+ )
293
+ for t in result.get("tools", [])
294
+ ]
295
+ except Exception:
296
+ self._tools = []
297
+
298
+ # Get resources
299
+ try:
300
+ result = await self._transport.send({
301
+ "jsonrpc": "2.0",
302
+ "method": "resources/list",
303
+ "params": {},
304
+ })
305
+ self._resources = [
306
+ MCPResource(
307
+ uri=r["uri"],
308
+ name=r["name"],
309
+ description=r.get("description", ""),
310
+ mime_type=r.get("mimeType", "text/plain"),
311
+ )
312
+ for r in result.get("resources", [])
313
+ ]
314
+ except Exception:
315
+ self._resources = []
316
+
317
+ async def disconnect(self) -> None:
318
+ """Disconnect from the MCP server."""
319
+ if self._transport:
320
+ await self._transport.disconnect()
321
+ self._transport = None
322
+
323
+ async def list_tools(self) -> list[MCPTool]:
324
+ """Get list of available tools."""
325
+ return self._tools.copy()
326
+
327
+ async def list_resources(self) -> list[MCPResource]:
328
+ """Get list of available resources."""
329
+ return self._resources.copy()
330
+
331
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
332
+ """
333
+ Call a tool on the MCP server.
334
+
335
+ Args:
336
+ name: Tool name
337
+ arguments: Tool arguments
338
+
339
+ Returns:
340
+ Tool result
341
+ """
342
+ if not self._transport:
343
+ raise RuntimeError("Not connected")
344
+
345
+ result = await self._transport.send({
346
+ "jsonrpc": "2.0",
347
+ "method": "tools/call",
348
+ "params": {
349
+ "name": name,
350
+ "arguments": arguments,
351
+ },
352
+ })
353
+
354
+ # Extract content from result
355
+ content = result.get("content", [])
356
+ if content and len(content) == 1:
357
+ return content[0].get("text", content[0])
358
+ return content
359
+
360
+ async def read_resource(self, uri: str) -> str:
361
+ """
362
+ Read a resource from the MCP server.
363
+
364
+ Args:
365
+ uri: Resource URI
366
+
367
+ Returns:
368
+ Resource content
369
+ """
370
+ if not self._transport:
371
+ raise RuntimeError("Not connected")
372
+
373
+ result = await self._transport.send({
374
+ "jsonrpc": "2.0",
375
+ "method": "resources/read",
376
+ "params": {"uri": uri},
377
+ })
378
+
379
+ contents = result.get("contents", [])
380
+ if contents:
381
+ return contents[0].get("text", "")
382
+ return ""
383
+
384
+ def get_copex_tools(self) -> list[dict[str, Any]]:
385
+ """
386
+ Get tools formatted for Copex session.
387
+
388
+ Returns:
389
+ List of tool definitions for create_session
390
+ """
391
+ return [
392
+ {
393
+ "name": tool.name,
394
+ "description": tool.description,
395
+ "parameters": tool.input_schema,
396
+ }
397
+ for tool in self._tools
398
+ ]
399
+
400
+
401
+ class MCPManager:
402
+ """
403
+ Manages multiple MCP server connections.
404
+
405
+ Usage:
406
+ manager = MCPManager()
407
+
408
+ # Add servers
409
+ manager.add_server(MCPServerConfig(
410
+ name="github",
411
+ command="npx",
412
+ args=["-y", "@github/mcp-server"],
413
+ ))
414
+
415
+ # Connect all
416
+ await manager.connect_all()
417
+
418
+ # Get all tools across servers
419
+ all_tools = manager.get_all_tools()
420
+
421
+ # Call a tool (auto-routes to correct server)
422
+ result = await manager.call_tool("github:search_repos", {...})
423
+
424
+ await manager.disconnect_all()
425
+ """
426
+
427
+ def __init__(self):
428
+ self._servers: dict[str, MCPServerConfig] = {}
429
+ self._clients: dict[str, MCPClient] = {}
430
+
431
+ def add_server(self, config: MCPServerConfig) -> None:
432
+ """Add an MCP server configuration."""
433
+ self._servers[config.name] = config
434
+
435
+ def remove_server(self, name: str) -> None:
436
+ """Remove an MCP server."""
437
+ self._servers.pop(name, None)
438
+
439
+ async def connect(self, name: str) -> MCPClient:
440
+ """Connect to a specific server."""
441
+ config = self._servers.get(name)
442
+ if not config:
443
+ raise ValueError(f"Unknown server: {name}")
444
+
445
+ client = MCPClient(config)
446
+ await client.connect()
447
+ self._clients[name] = client
448
+ return client
449
+
450
+ async def connect_all(self) -> None:
451
+ """Connect to all configured servers."""
452
+ for name in self._servers:
453
+ if name not in self._clients:
454
+ await self.connect(name)
455
+
456
+ async def disconnect(self, name: str) -> None:
457
+ """Disconnect from a specific server."""
458
+ client = self._clients.pop(name, None)
459
+ if client:
460
+ await client.disconnect()
461
+
462
+ async def disconnect_all(self) -> None:
463
+ """Disconnect from all servers."""
464
+ for name in list(self._clients.keys()):
465
+ await self.disconnect(name)
466
+
467
+ def get_client(self, name: str) -> MCPClient | None:
468
+ """Get a connected client by name."""
469
+ return self._clients.get(name)
470
+
471
+ def get_all_tools(self) -> list[dict[str, Any]]:
472
+ """Get all tools across all connected servers."""
473
+ tools = []
474
+ for server_name, client in self._clients.items():
475
+ for tool in client._tools:
476
+ tools.append({
477
+ "name": f"{server_name}:{tool.name}",
478
+ "description": f"[{server_name}] {tool.description}",
479
+ "parameters": tool.input_schema,
480
+ "_server": server_name,
481
+ "_tool": tool.name,
482
+ })
483
+ return tools
484
+
485
+ async def call_tool(self, qualified_name: str, arguments: dict[str, Any]) -> Any:
486
+ """
487
+ Call a tool by qualified name (server:tool).
488
+
489
+ Args:
490
+ qualified_name: "server_name:tool_name"
491
+ arguments: Tool arguments
492
+
493
+ Returns:
494
+ Tool result
495
+ """
496
+ if ":" not in qualified_name:
497
+ raise ValueError(f"Expected qualified name 'server:tool', got: {qualified_name}")
498
+
499
+ server_name, tool_name = qualified_name.split(":", 1)
500
+ client = self._clients.get(server_name)
501
+
502
+ if not client:
503
+ raise ValueError(f"Server not connected: {server_name}")
504
+
505
+ return await client.call_tool(tool_name, arguments)
506
+
507
+
508
+ def load_mcp_config(path: Path | str | None = None) -> list[MCPServerConfig]:
509
+ """
510
+ Load MCP server configurations from file.
511
+
512
+ Default locations:
513
+ - .copex/mcp.json (project)
514
+ - ~/.copex/mcp.json (global)
515
+
516
+ Config format:
517
+ {
518
+ "servers": {
519
+ "github": {
520
+ "command": "npx",
521
+ "args": ["-y", "@github/mcp-server"],
522
+ "env": {"GITHUB_TOKEN": "..."}
523
+ }
524
+ }
525
+ }
526
+ """
527
+ if path:
528
+ config_path = Path(path)
529
+ else:
530
+ # Try project, then global
531
+ project_path = Path(".copex/mcp.json")
532
+ global_path = Path.home() / ".copex" / "mcp.json"
533
+
534
+ if project_path.exists():
535
+ config_path = project_path
536
+ elif global_path.exists():
537
+ config_path = global_path
538
+ else:
539
+ return []
540
+
541
+ if not config_path.exists():
542
+ return []
543
+
544
+ with open(config_path, "r", encoding="utf-8") as f:
545
+ data = json.load(f)
546
+
547
+ servers = []
548
+ for name, config in data.get("servers", {}).items():
549
+ servers.append(MCPServerConfig(
550
+ name=name,
551
+ command=config["command"],
552
+ args=config.get("args", []),
553
+ env=config.get("env", {}),
554
+ cwd=config.get("cwd"),
555
+ transport=config.get("transport", "stdio"),
556
+ url=config.get("url"),
557
+ auto_start=config.get("auto_start", True),
558
+ restart_on_crash=config.get("restart_on_crash", True),
559
+ ))
560
+
561
+ return servers