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.
- open_edison-0.1.10.dist-info/METADATA +332 -0
- open_edison-0.1.10.dist-info/RECORD +17 -0
- open_edison-0.1.10.dist-info/WHEEL +4 -0
- open_edison-0.1.10.dist-info/entry_points.txt +3 -0
- open_edison-0.1.10.dist-info/licenses/LICENSE +674 -0
- src/__init__.py +11 -0
- src/__main__.py +10 -0
- src/cli.py +274 -0
- src/config.py +224 -0
- src/frontend_dist/assets/index-CKkid2y-.js +51 -0
- src/frontend_dist/assets/index-CRxojymD.css +1 -0
- src/frontend_dist/index.html +21 -0
- src/mcp_manager.py +137 -0
- src/middleware/data_access_tracker.py +510 -0
- src/middleware/session_tracking.py +477 -0
- src/server.py +560 -0
- src/single_user_mcp.py +403 -0
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")
|