open-edison 0.1.10__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.
src/single_user_mcp.py ADDED
@@ -0,0 +1,403 @@
1
+ """
2
+ Single User MCP Server
3
+
4
+ FastMCP instance for the single-user Open Edison setup.
5
+ Handles MCP protocol communication with running servers using a unified composite proxy.
6
+ """
7
+
8
+ from typing import Any, TypedDict
9
+
10
+ from fastmcp import FastMCP
11
+ from loguru import logger as log
12
+
13
+ from src.config import MCPServerConfig, config
14
+ from src.mcp_manager import MCPManager
15
+ from src.middleware.session_tracking import (
16
+ SessionTrackingMiddleware,
17
+ get_current_session_data_tracker,
18
+ )
19
+
20
+
21
+ class MountedServerInfo(TypedDict):
22
+ """Type definition for mounted server information."""
23
+
24
+ config: MCPServerConfig
25
+ proxy: FastMCP[Any] | None
26
+
27
+
28
+ class ServerStatusInfo(TypedDict):
29
+ """Type definition for server status information."""
30
+
31
+ name: str
32
+ config: dict[str, str | list[str] | bool | dict[str, str] | None]
33
+ mounted: bool
34
+
35
+
36
+ class SingleUserMCP(FastMCP[Any]):
37
+ """
38
+ Single-user MCP server implementation for Open Edison.
39
+
40
+ This class extends FastMCP to handle MCP protocol communication
41
+ in a single-user environment using a unified composite proxy approach.
42
+ All enabled MCP servers are mounted through a single FastMCP composite proxy.
43
+ """
44
+
45
+ def __init__(self, mcp_manager: MCPManager):
46
+ super().__init__(name="open-edison-single-user")
47
+ self.mcp_manager: MCPManager = mcp_manager
48
+ self.mounted_servers: dict[str, MountedServerInfo] = {}
49
+ self.composite_proxy: FastMCP[Any] | None = None
50
+
51
+ # Add session tracking middleware for data access monitoring
52
+ self.add_middleware(SessionTrackingMiddleware())
53
+
54
+ # Add built-in demo tools
55
+ self._setup_demo_tools()
56
+ self._setup_demo_resources()
57
+ self._setup_demo_prompts()
58
+
59
+ def _convert_to_fastmcp_config(self, enabled_servers: list[MCPServerConfig]) -> dict[str, Any]:
60
+ """
61
+ Convert Open Edison config format to FastMCP MCPConfig format.
62
+
63
+ Args:
64
+ enabled_servers: List of enabled MCP server configurations
65
+
66
+ Returns:
67
+ Dictionary in FastMCP MCPConfig format for composite proxy
68
+ """
69
+ mcp_servers: dict[str, dict[str, Any]] = {}
70
+
71
+ for server_config in enabled_servers:
72
+ # Skip test servers for composite proxy
73
+ if server_config.command == "echo":
74
+ continue
75
+
76
+ server_entry: dict[str, Any] = {
77
+ "command": server_config.command,
78
+ "args": server_config.args,
79
+ "env": server_config.env or {},
80
+ }
81
+
82
+ # Add roots if specified
83
+ if server_config.roots:
84
+ server_entry["roots"] = server_config.roots
85
+
86
+ mcp_servers[server_config.name] = server_entry
87
+
88
+ return {"mcpServers": mcp_servers}
89
+
90
+ async def _mount_test_server(self, server_config: MCPServerConfig) -> bool:
91
+ """Mount a test server with mock configuration."""
92
+ log.info(f"Mock mounting test server: {server_config.name}")
93
+ self.mounted_servers[server_config.name] = MountedServerInfo(
94
+ config=server_config, proxy=None
95
+ )
96
+ log.info(f"✅ Mounted test server: {server_config.name}")
97
+ return True
98
+
99
+ async def create_composite_proxy(self, enabled_servers: list[MCPServerConfig]) -> bool:
100
+ """
101
+ Create a unified composite proxy for all enabled MCP servers.
102
+
103
+ This replaces individual server mounting with a single FastMCP composite proxy
104
+ that handles all configured servers with automatic namespacing.
105
+
106
+ Args:
107
+ enabled_servers: List of enabled MCP server configurations
108
+
109
+ Returns:
110
+ True if composite proxy was created successfully, False otherwise
111
+ """
112
+ try:
113
+ # Filter out test servers for composite proxy
114
+ real_servers = [s for s in enabled_servers if s.command != "echo"]
115
+
116
+ if not real_servers:
117
+ log.info("No real servers to mount in composite proxy")
118
+ return True
119
+
120
+ # Start all required server processes
121
+ for server_config in real_servers:
122
+ if not await self.mcp_manager.is_server_running(server_config.name):
123
+ log.info(f"Starting server process: {server_config.name}")
124
+ _ = await self.mcp_manager.start_server(server_config.name)
125
+
126
+ # Convert to FastMCP config format
127
+ fastmcp_config = self._convert_to_fastmcp_config(real_servers)
128
+
129
+ log.info(
130
+ f"Creating composite proxy for servers: {list(fastmcp_config['mcpServers'].keys())}"
131
+ )
132
+
133
+ # Create the composite proxy using FastMCP's multi-server support
134
+ self.composite_proxy = FastMCP.as_proxy(
135
+ backend=fastmcp_config, name="open-edison-composite-proxy"
136
+ )
137
+
138
+ # Import the composite proxy into this main server
139
+ # Tools and resources will be automatically namespaced by server name
140
+ await self.import_server(self.composite_proxy)
141
+
142
+ # Track mounted servers for status reporting
143
+ for server_config in real_servers:
144
+ self.mounted_servers[server_config.name] = MountedServerInfo(
145
+ config=server_config, proxy=self.composite_proxy
146
+ )
147
+
148
+ log.info(f"✅ Created composite proxy with {len(real_servers)} servers")
149
+ return True
150
+
151
+ except Exception as e:
152
+ log.error(f"❌ Failed to create composite proxy: {e}")
153
+ return False
154
+
155
+ async def mount_server(self, server_config: MCPServerConfig) -> bool:
156
+ """
157
+ Mount a single MCP server by rebuilding the composite proxy.
158
+
159
+ Args:
160
+ server_config: Configuration for the server to mount
161
+
162
+ Returns:
163
+ True if mounting was successful, False otherwise
164
+ """
165
+ try:
166
+ # Check if server is already mounted
167
+ if server_config.name in self.mounted_servers:
168
+ log.info(f"Server {server_config.name} is already mounted")
169
+ return True
170
+
171
+ # Handle test servers separately
172
+ if server_config.command == "echo":
173
+ return await self._mount_test_server(server_config)
174
+
175
+ # For real servers, we need to rebuild the composite proxy
176
+ log.info(f"Mounting server {server_config.name} via composite proxy rebuild")
177
+
178
+ # Get currently mounted servers and add the new one
179
+ current_configs = [mounted["config"] for mounted in self.mounted_servers.values()]
180
+
181
+ # Add the new server if not already there
182
+ if server_config not in current_configs:
183
+ current_configs.append(server_config)
184
+
185
+ # Rebuild composite proxy with new server list
186
+ return await self.create_composite_proxy(current_configs)
187
+
188
+ except Exception as e:
189
+ log.error(f"❌ Failed to mount server {server_config.name}: {e}")
190
+ return False
191
+
192
+ async def unmount_server(self, server_name: str) -> bool:
193
+ """
194
+ Unmount an MCP server and stop its subprocess.
195
+
196
+ NOTE: For servers in the composite proxy, this will require rebuilding
197
+ the entire composite proxy without the specified server.
198
+ """
199
+ try:
200
+ # Check if this is a test server (individually mounted)
201
+ if server_name in self.mounted_servers:
202
+ mounted = self.mounted_servers[server_name]
203
+ if mounted["config"].command == "echo":
204
+ # Test server - handle individually
205
+ await self._cleanup_mounted_server(server_name)
206
+ return True
207
+
208
+ # Real server in composite proxy - needs full rebuild
209
+ log.warning(f"Unmounting {server_name} requires rebuilding composite proxy")
210
+ return await self._rebuild_composite_proxy_without(server_name)
211
+
212
+ log.warning(f"Server {server_name} not found in mounted servers")
213
+ return False
214
+
215
+ except Exception as e:
216
+ log.error(f"❌ Failed to unmount MCP server {server_name}: {e}")
217
+ return False
218
+
219
+ async def _rebuild_composite_proxy_without(self, excluded_server: str) -> bool:
220
+ """Rebuild the composite proxy without the specified server."""
221
+ try:
222
+ # Remove from mounted servers
223
+ await self._cleanup_mounted_server(excluded_server)
224
+ await self._stop_server_process(excluded_server)
225
+
226
+ # Get remaining servers that should be in composite proxy
227
+ remaining_configs = [
228
+ mounted["config"]
229
+ for name, mounted in self.mounted_servers.items()
230
+ if mounted["config"].command != "echo" and name != excluded_server
231
+ ]
232
+
233
+ if not remaining_configs:
234
+ log.info("No servers remaining for composite proxy")
235
+ self.composite_proxy = None
236
+ return True
237
+
238
+ # Rebuild composite proxy with remaining servers
239
+ log.info(f"Rebuilding composite proxy without {excluded_server}")
240
+ return await self.create_composite_proxy(remaining_configs)
241
+
242
+ except Exception as e:
243
+ log.error(f"Failed to rebuild composite proxy: {e}")
244
+ return False
245
+
246
+ async def _cleanup_mounted_server(self, server_name: str) -> None:
247
+ """Clean up mounted server resources."""
248
+ if server_name in self.mounted_servers:
249
+ del self.mounted_servers[server_name]
250
+ log.info(f"✅ Unmounted MCP server: {server_name}")
251
+
252
+ async def _stop_server_process(self, server_name: str) -> None:
253
+ """Stop the server subprocess if it's not a test server."""
254
+ if server_name != "test-echo":
255
+ await self.mcp_manager.stop_server(server_name)
256
+
257
+ async def get_mounted_servers(self) -> list[ServerStatusInfo]:
258
+ """Get list of currently mounted servers."""
259
+ return [
260
+ ServerStatusInfo(name=name, config=mounted["config"].__dict__, mounted=True)
261
+ for name, mounted in self.mounted_servers.items()
262
+ ]
263
+
264
+ async def initialize(self, test_config: Any | None = None) -> None:
265
+ """Initialize the FastMCP server using unified composite proxy approach."""
266
+ log.info("Initializing Single User MCP server with composite proxy")
267
+ config_to_use = test_config if test_config is not None else config
268
+ log.debug(f"Available MCP servers in config: {[s.name for s in config_to_use.mcp_servers]}")
269
+
270
+ # Get all enabled servers
271
+ enabled_servers = [s for s in config_to_use.mcp_servers if s.enabled]
272
+ log.info(
273
+ f"Found {len(enabled_servers)} enabled servers: {[s.name for s in enabled_servers]}"
274
+ )
275
+
276
+ # Mount test servers individually (they don't go in composite proxy)
277
+ test_servers = [s for s in enabled_servers if s.command == "echo"]
278
+ for server_config in test_servers:
279
+ log.info(f"Mounting test server individually: {server_config.name}")
280
+ _ = await self._mount_test_server(server_config)
281
+
282
+ # Create composite proxy for all real servers
283
+ success = await self.create_composite_proxy(enabled_servers)
284
+ if not success:
285
+ log.error("Failed to create composite proxy")
286
+ return
287
+
288
+ log.info("✅ Single User MCP server initialized with composite proxy")
289
+
290
+ def _calculate_risk_level(self, trifecta: dict[str, bool]) -> str:
291
+ """
292
+ Calculate a human-readable risk level based on trifecta flags.
293
+
294
+ Args:
295
+ trifecta: Dictionary with the three trifecta flags
296
+
297
+ Returns:
298
+ Risk level as string
299
+ """
300
+ risk_count = sum(
301
+ [
302
+ trifecta.get("has_private_data_access", False),
303
+ trifecta.get("has_untrusted_content_exposure", False),
304
+ trifecta.get("has_external_communication", False),
305
+ ]
306
+ )
307
+
308
+ risk_levels = {
309
+ 0: "LOW",
310
+ 1: "MEDIUM",
311
+ 2: "HIGH",
312
+ }
313
+ return risk_levels.get(risk_count, "CRITICAL")
314
+
315
+ def _setup_demo_tools(self) -> None:
316
+ """Set up built-in demo tools for testing."""
317
+
318
+ @self.tool()
319
+ def echo(text: str) -> str: # noqa: ARG001
320
+ """
321
+ Echo back the provided text.
322
+
323
+ Args:
324
+ text: The text to echo back
325
+
326
+ Returns:
327
+ The same text that was provided
328
+ """
329
+ log.info(f"🔊 Echo tool called with: {text}")
330
+ return f"Echo: {text}"
331
+
332
+ @self.tool()
333
+ def get_server_info() -> dict[str, str | list[str] | int]: # noqa: ARG001
334
+ """
335
+ Get information about the Open Edison server.
336
+
337
+ Returns:
338
+ Dictionary with server information
339
+ """
340
+ log.info("â„šī¸ Server info tool called")
341
+ return {
342
+ "name": "Open Edison Single User",
343
+ "version": config.version,
344
+ "mounted_servers": list(self.mounted_servers.keys()),
345
+ "total_mounted": len(self.mounted_servers),
346
+ }
347
+
348
+ @self.tool()
349
+ def get_security_status() -> dict[str, Any]: # noqa: ARG001
350
+ """
351
+ Get the current session's security status and data access summary.
352
+
353
+ Returns:
354
+ Dictionary with security information including lethal trifecta status
355
+ """
356
+ log.info("🔒 Security status tool called")
357
+
358
+ tracker = get_current_session_data_tracker()
359
+ if tracker is None:
360
+ return {"error": "No active session found", "security_status": "unknown"}
361
+
362
+ security_data = tracker.to_dict()
363
+ trifecta = security_data["lethal_trifecta"]
364
+
365
+ # Add human-readable status
366
+ security_data["security_status"] = (
367
+ "HIGH_RISK" if trifecta["trifecta_achieved"] else "MONITORING"
368
+ )
369
+ security_data["risk_level"] = self._calculate_risk_level(trifecta)
370
+
371
+ return security_data
372
+
373
+ log.info("✅ Added built-in demo tools: echo, get_server_info, get_security_status")
374
+
375
+ def _setup_demo_resources(self) -> None:
376
+ """Set up built-in demo resources for testing."""
377
+
378
+ @self.resource("config://app")
379
+ def get_app_config() -> dict[str, Any]: # noqa: ARG001
380
+ """Get application configuration."""
381
+ return {
382
+ "version": config.version,
383
+ "mounted_servers": list(self.mounted_servers.keys()),
384
+ "total_mounted": len(self.mounted_servers),
385
+ }
386
+
387
+ log.info("✅ Added built-in demo resources: config://app")
388
+
389
+ def _setup_demo_prompts(self) -> None:
390
+ """Set up built-in demo prompts for testing."""
391
+
392
+ @self.prompt()
393
+ def summarize_text(text: str) -> str:
394
+ """Create a prompt to summarize the given text."""
395
+ return f"""
396
+ Please provide a concise, one-paragraph summary of the following text:
397
+
398
+ {text}
399
+
400
+ Focus on the main points and key takeaways.
401
+ """
402
+
403
+ log.info("✅ Added built-in demo prompts: summarize_text")