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/server.py ADDED
@@ -0,0 +1,560 @@
1
+ """
2
+ Open Edison Server
3
+
4
+ Simple FastAPI + FastMCP server for single-user MCP proxy.
5
+ No multi-user support, no complex routing - just a straightforward proxy.
6
+ """
7
+
8
+ import asyncio
9
+ from collections.abc import Awaitable, Callable, Coroutine
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ import uvicorn
14
+ from fastapi import Depends, FastAPI, HTTPException, status
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, JSONResponse, Response
17
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
18
+ from fastapi.staticfiles import StaticFiles
19
+ from loguru import logger as log
20
+
21
+ from src.config import MCPServerConfig, config
22
+ from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
23
+ from src.mcp_manager import MCPManager
24
+ from src.middleware.session_tracking import (
25
+ MCPSessionModel,
26
+ create_db_session,
27
+ )
28
+ from src.single_user_mcp import SingleUserMCP
29
+
30
+
31
+ def _get_current_config():
32
+ """Get current config, allowing for test mocking."""
33
+ from src.config import config as current_config
34
+
35
+ return current_config
36
+
37
+
38
+ # Module-level dependency singletons
39
+ _security = HTTPBearer()
40
+ _auth_dependency = Depends(_security)
41
+
42
+
43
+ class OpenEdisonProxy:
44
+ """
45
+ Open Edison Single-User MCP Proxy Server
46
+
47
+ Runs both FastAPI (for management API) and FastMCP (for MCP protocol)
48
+ on different ports, similar to edison-watch but simplified for single-user.
49
+ """
50
+
51
+ def __init__(self, host: str = "localhost", port: int = 3000):
52
+ self.host: str = host
53
+ self.port: int = port
54
+
55
+ # Initialize components
56
+ self.mcp_manager: MCPManager = MCPManager()
57
+ self.single_user_mcp: SingleUserMCP = SingleUserMCP(self.mcp_manager)
58
+
59
+ # Initialize FastAPI app for management
60
+ self.fastapi_app: FastAPI = self._create_fastapi_app()
61
+
62
+ def _create_fastapi_app(self) -> FastAPI: # noqa: C901 - centralized app wiring
63
+ """Create and configure FastAPI application"""
64
+ app = FastAPI(
65
+ title="Open Edison MCP Proxy",
66
+ description="Single-user MCP proxy server",
67
+ version="0.1.0",
68
+ )
69
+
70
+ # Add CORS middleware
71
+ app.add_middleware(
72
+ CORSMiddleware,
73
+ allow_origins=["*"], # In production, be more restrictive
74
+ allow_credentials=True,
75
+ allow_methods=["*"],
76
+ allow_headers=["*"],
77
+ )
78
+
79
+ # Register all routes
80
+ self._register_routes(app)
81
+
82
+ # If packaged frontend assets exist, mount at /dashboard
83
+ try:
84
+ # Prefer packaged assets under src/frontend_dist
85
+ static_dir = Path(__file__).parent / "frontend_dist"
86
+ if not static_dir.exists():
87
+ # Fallback to repo root or site-packages root (older layout)
88
+ static_dir = Path(__file__).parent.parent / "frontend_dist"
89
+ if static_dir.exists():
90
+ app.mount(
91
+ "/dashboard",
92
+ StaticFiles(directory=str(static_dir), html=True),
93
+ name="dashboard",
94
+ )
95
+ assets_dir = static_dir / "assets"
96
+ if assets_dir.exists():
97
+ app.mount(
98
+ "/assets",
99
+ StaticFiles(directory=str(assets_dir), html=False),
100
+ name="dashboard-assets",
101
+ )
102
+ favicon_path = static_dir / "favicon.ico"
103
+ if favicon_path.exists():
104
+
105
+ async def _favicon() -> FileResponse: # type: ignore[override]
106
+ return FileResponse(str(favicon_path))
107
+
108
+ app.add_api_route("/favicon.ico", _favicon, methods=["GET"]) # type: ignore[arg-type]
109
+ log.info(f"📊 Dashboard static assets mounted at /dashboard from {static_dir}")
110
+ else:
111
+ log.debug("No packaged frontend assets found; skipping static mount")
112
+ except Exception as mount_err: # noqa: BLE001
113
+ log.warning(f"Failed to mount dashboard static assets: {mount_err}")
114
+
115
+ # Special-case: serve SQLite db and config JSONs for dashboard (prod replacement for Vite @fs)
116
+ def _resolve_db_path() -> Path | None:
117
+ try:
118
+ # Try configured database path first
119
+ db_cfg = getattr(config.logging, "database_path", None)
120
+ if isinstance(db_cfg, str) and db_cfg:
121
+ db_path = Path(db_cfg)
122
+ if db_path.is_absolute() and db_path.exists():
123
+ return db_path
124
+ # Check relative to config dir
125
+ try:
126
+ cfg_dir = _get_cfg_dir()
127
+ except Exception:
128
+ cfg_dir = Path.cwd()
129
+ rel1 = cfg_dir / db_path
130
+ if rel1.exists():
131
+ return rel1
132
+ # Also check relative to cwd as a fallback
133
+ rel2 = Path.cwd() / db_path
134
+ if rel2.exists():
135
+ return rel2
136
+ except Exception:
137
+ pass
138
+
139
+ # Fallback common locations
140
+ try:
141
+ cfg_dir = _get_cfg_dir()
142
+ except Exception:
143
+ cfg_dir = Path.cwd()
144
+ candidates = [
145
+ cfg_dir / "sessions.db",
146
+ cfg_dir / "sessions.db",
147
+ Path.cwd() / "edison.db",
148
+ Path.cwd() / "sessions.db",
149
+ ]
150
+ for c in candidates:
151
+ if c.exists():
152
+ return c
153
+ return None
154
+
155
+ async def _serve_db() -> FileResponse: # type: ignore[override]
156
+ db_file = _resolve_db_path()
157
+ if db_file is None:
158
+ raise HTTPException(status_code=404, detail="Database file not found")
159
+ return FileResponse(str(db_file), media_type="application/octet-stream")
160
+
161
+ # Provide multiple paths the SPA might attempt (both edison.db legacy and sessions.db canonical)
162
+ for name in ("edison.db", "sessions.db"):
163
+ app.add_api_route(f"/dashboard/{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
164
+ app.add_api_route(f"/{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
165
+ app.add_api_route(f"/@fs/dashboard//{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
166
+ app.add_api_route(f"/@fs/{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
167
+ # Also support URL-encoded '@' prefix used by some bundlers
168
+ app.add_api_route(f"/%40fs/dashboard//{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
169
+ app.add_api_route(f"/%40fs/{name}", _serve_db, methods=["GET"]) # type: ignore[arg-type]
170
+
171
+ # Config files (read + write)
172
+ allowed_json_files = {
173
+ "config.json",
174
+ "tool_permissions.json",
175
+ "resource_permissions.json",
176
+ "prompt_permissions.json",
177
+ }
178
+
179
+ def _resolve_json_path(filename: str) -> Path:
180
+ # JSON files reside in the config directory
181
+ try:
182
+ base = _get_cfg_dir()
183
+ except Exception:
184
+ base = Path.cwd()
185
+ target = base / filename
186
+ # If missing and we ship a default in package root, bootstrap it
187
+ if not target.exists():
188
+ try:
189
+ pkg_default = Path(__file__).parent.parent / filename
190
+ if pkg_default.exists():
191
+ target.write_text(pkg_default.read_text(encoding="utf-8"), encoding="utf-8")
192
+ except Exception:
193
+ pass
194
+ return target
195
+
196
+ async def _serve_json(filename: str) -> Response: # type: ignore[override]
197
+ if filename not in allowed_json_files:
198
+ raise HTTPException(status_code=404, detail="Not found")
199
+ json_path = _resolve_json_path(filename)
200
+ if not json_path.exists():
201
+ # Return empty object for missing files to avoid hard failures in UI
202
+ return JSONResponse(content={}, media_type="application/json")
203
+ return FileResponse(str(json_path), media_type="application/json")
204
+
205
+ def _json_endpoint_factory(name: str) -> Callable[[], Awaitable[Response]]:
206
+ async def endpoint() -> Response:
207
+ return await _serve_json(name)
208
+
209
+ return endpoint
210
+
211
+ # GET endpoints for convenience
212
+ for name in allowed_json_files:
213
+ app.add_api_route(f"/{name}", _json_endpoint_factory(name), methods=["GET"]) # type: ignore[arg-type]
214
+ app.add_api_route(f"/dashboard/{name}", _json_endpoint_factory(name), methods=["GET"]) # type: ignore[arg-type]
215
+
216
+ # Save endpoint to persist JSON changes
217
+ async def _save_json(body: dict[str, Any]) -> dict[str, str]: # type: ignore[override]
218
+ try:
219
+ # Accept either {path, content} or {name, content}
220
+ name = body.get("name")
221
+ path_val = body.get("path")
222
+ content = body.get("content", "")
223
+ if not isinstance(content, str):
224
+ raise ValueError("content must be string")
225
+ if isinstance(name, str) and name in allowed_json_files:
226
+ target = _resolve_json_path(name)
227
+ elif isinstance(path_val, str):
228
+ base = Path.cwd()
229
+ # Normalize path but restrict to allowed filenames
230
+ candidate = Path(path_val)
231
+ filename = candidate.name
232
+ if filename not in allowed_json_files:
233
+ raise ValueError("filename not allowed")
234
+ target = base / filename
235
+ else:
236
+ raise ValueError("invalid target file")
237
+ # Basic validation to ensure valid JSON
238
+ import json as _json
239
+
240
+ _ = _json.loads(content or "{}")
241
+ target.write_text(content or "{}", encoding="utf-8")
242
+ return {"status": "ok"}
243
+ except Exception as e: # noqa: BLE001
244
+ raise HTTPException(status_code=400, detail=f"Save failed: {e}") from e
245
+
246
+ app.add_api_route("/__save_json__", _save_json, methods=["POST"]) # type: ignore[arg-type]
247
+
248
+ # Catch-all for @fs patterns; serve known db and json filenames
249
+ async def _serve_fs_path(rest: str): # type: ignore[override]
250
+ target = rest.strip("/")
251
+ # Basename-based allowlist
252
+ basename = Path(target).name
253
+ if basename in allowed_json_files:
254
+ return await _serve_json(basename)
255
+ if basename.endswith(("edison.db", "sessions.db")):
256
+ return await _serve_db()
257
+ raise HTTPException(status_code=404, detail="Not found")
258
+
259
+ app.add_api_route("/@fs/{rest:path}", _serve_fs_path, methods=["GET"]) # type: ignore[arg-type]
260
+ app.add_api_route("/%40fs/{rest:path}", _serve_fs_path, methods=["GET"]) # type: ignore[arg-type]
261
+
262
+ return app
263
+
264
+ async def start(self) -> None:
265
+ """Start the Open Edison proxy server"""
266
+ log.info("🚀 Starting Open Edison MCP Proxy Server")
267
+ log.info(f"FastAPI management API on {self.host}:{self.port + 1}")
268
+ log.info(f"FastMCP protocol server on {self.host}:{self.port}")
269
+
270
+ # Ensure the sessions database exists and has the required schema
271
+ try:
272
+ with create_db_session():
273
+ pass
274
+ except Exception as db_err: # noqa: BLE001
275
+ log.warning(f"Failed to pre-initialize sessions database: {db_err}")
276
+
277
+ # Initialize the FastMCP server (this handles starting enabled MCP servers)
278
+ await self.single_user_mcp.initialize()
279
+
280
+ # Add CORS middleware to FastAPI
281
+ self.fastapi_app.add_middleware(
282
+ CORSMiddleware,
283
+ allow_origins=["*"], # In production, be more restrictive
284
+ allow_credentials=True,
285
+ allow_methods=["*"],
286
+ allow_headers=["*"],
287
+ )
288
+
289
+ # Create server configurations
290
+ servers_to_run: list[Coroutine[Any, Any, None]] = []
291
+
292
+ # FastAPI management server on port 3001
293
+ fastapi_config = uvicorn.Config(
294
+ app=self.fastapi_app,
295
+ host=self.host,
296
+ port=self.port + 1,
297
+ log_level=config.logging.level.lower(),
298
+ )
299
+ fastapi_server = uvicorn.Server(fastapi_config)
300
+ servers_to_run.append(fastapi_server.serve())
301
+
302
+ # FastMCP protocol server on port 3000 (stateful for session persistence)
303
+ mcp_app = self.single_user_mcp.http_app(path="/mcp/", stateless_http=False)
304
+ fastmcp_config = uvicorn.Config(
305
+ app=mcp_app,
306
+ host=self.host,
307
+ port=self.port,
308
+ log_level=config.logging.level.lower(),
309
+ )
310
+ fastmcp_server = uvicorn.Server(fastmcp_config)
311
+ servers_to_run.append(fastmcp_server.serve())
312
+
313
+ # Run both servers concurrently
314
+ log.info("🚀 Starting both FastAPI and FastMCP servers...")
315
+ _ = await asyncio.gather(*servers_to_run)
316
+
317
+ async def shutdown(self) -> None:
318
+ """Shutdown the proxy server and all MCP servers"""
319
+ log.info("🛑 Shutting down Open Edison proxy server")
320
+ await self.mcp_manager.shutdown()
321
+ log.info("✅ Open Edison proxy server shutdown complete")
322
+
323
+ def _register_routes(self, app: FastAPI) -> None:
324
+ """Register all routes for the FastAPI app"""
325
+ # Register routes with their decorators
326
+ app.add_api_route("/health", self.health_check, methods=["GET"])
327
+ app.add_api_route(
328
+ "/mcp/status",
329
+ self.mcp_status,
330
+ methods=["GET"],
331
+ dependencies=[Depends(self.verify_api_key)],
332
+ )
333
+ app.add_api_route(
334
+ "/mcp/{server_name}/start",
335
+ self.start_mcp_server,
336
+ methods=["POST"],
337
+ dependencies=[Depends(self.verify_api_key)],
338
+ )
339
+ app.add_api_route(
340
+ "/mcp/{server_name}/stop",
341
+ self.stop_mcp_server,
342
+ methods=["POST"],
343
+ dependencies=[Depends(self.verify_api_key)],
344
+ )
345
+ app.add_api_route(
346
+ "/mcp/call",
347
+ self.proxy_mcp_call,
348
+ methods=["POST"],
349
+ dependencies=[Depends(self.verify_api_key)],
350
+ )
351
+ app.add_api_route(
352
+ "/mcp/mounted",
353
+ self.get_mounted_servers,
354
+ methods=["GET"],
355
+ dependencies=[Depends(self.verify_api_key)],
356
+ )
357
+ app.add_api_route(
358
+ "/mcp/{server_name}/mount",
359
+ self.mount_server,
360
+ methods=["POST"],
361
+ dependencies=[Depends(self.verify_api_key)],
362
+ )
363
+ app.add_api_route(
364
+ "/mcp/{server_name}/unmount",
365
+ self.unmount_server,
366
+ methods=["POST"],
367
+ dependencies=[Depends(self.verify_api_key)],
368
+ )
369
+ # Public sessions endpoint (no auth) for simple local dashboard
370
+ app.add_api_route(
371
+ "/sessions",
372
+ self.get_sessions,
373
+ methods=["GET"],
374
+ )
375
+
376
+ async def verify_api_key(
377
+ self, credentials: HTTPAuthorizationCredentials = _auth_dependency
378
+ ) -> str:
379
+ """
380
+ Dependency to verify API key from Authorization header.
381
+
382
+ Returns the API key string if valid, otherwise raises HTTPException.
383
+ """
384
+ current_config = _get_current_config()
385
+ if credentials.credentials != current_config.server.api_key:
386
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
387
+ return credentials.credentials
388
+
389
+ def _handle_server_operation_error(
390
+ self, operation: str, server_name: str, error: Exception
391
+ ) -> HTTPException:
392
+ """Handle common server operation errors."""
393
+ log.error(f"Failed to {operation} server {server_name}: {error}")
394
+ return HTTPException(
395
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
396
+ detail=f"Failed to {operation} server: {str(error)}",
397
+ )
398
+
399
+ def _find_server_config(self, server_name: str) -> MCPServerConfig:
400
+ """Find server configuration by name."""
401
+ current_config = _get_current_config()
402
+ for config_server in current_config.mcp_servers:
403
+ if config_server.name == server_name:
404
+ return config_server
405
+ raise HTTPException(
406
+ status_code=404,
407
+ detail=f"Server configuration not found: {server_name}",
408
+ )
409
+
410
+ async def health_check(self) -> dict[str, Any]:
411
+ """Health check endpoint"""
412
+ return {"status": "healthy", "version": "0.1.0", "mcp_servers": len(config.mcp_servers)}
413
+
414
+ async def mcp_status(self) -> dict[str, list[dict[str, str | bool]]]:
415
+ """Get status of configured MCP servers"""
416
+ return {
417
+ "servers": [
418
+ {
419
+ "name": server.name,
420
+ "enabled": server.enabled,
421
+ "running": await self.mcp_manager.is_server_running(server.name),
422
+ }
423
+ for server in config.mcp_servers
424
+ ]
425
+ }
426
+
427
+ async def start_mcp_server(self, server_name: str) -> dict[str, str]:
428
+ """Start a specific MCP server"""
429
+ try:
430
+ _ = await self.mcp_manager.start_server(server_name)
431
+ return {"message": f"Server {server_name} started successfully"}
432
+ except Exception as e:
433
+ raise self._handle_server_operation_error("start", server_name, e) from e
434
+
435
+ async def stop_mcp_server(self, server_name: str) -> dict[str, str]:
436
+ """Stop a specific MCP server"""
437
+ try:
438
+ await self.mcp_manager.stop_server(server_name)
439
+ return {"message": f"Server {server_name} stopped successfully"}
440
+ except Exception as e:
441
+ raise self._handle_server_operation_error("stop", server_name, e) from e
442
+
443
+ async def proxy_mcp_call(self, request: dict[str, Any]) -> dict[str, Any]:
444
+ """
445
+ Proxy MCP calls to mounted servers.
446
+
447
+ This now routes requests through the mounted FastMCP servers.
448
+ """
449
+ try:
450
+ log.info(f"Proxying MCP request: {request.get('method', 'unknown')}")
451
+
452
+ mounted = await self.single_user_mcp.get_mounted_servers()
453
+ mounted_names = [server["name"] for server in mounted]
454
+
455
+ return {
456
+ "jsonrpc": "2.0",
457
+ "id": request.get("id"),
458
+ "result": {
459
+ "message": "MCP request routed through FastMCP",
460
+ "request": request,
461
+ "mounted_servers": mounted_names,
462
+ },
463
+ }
464
+ except Exception as e:
465
+ log.error(f"Failed to proxy MCP call: {e}")
466
+ raise HTTPException(
467
+ status_code=500,
468
+ detail=f"Failed to proxy MCP call: {str(e)}",
469
+ ) from e
470
+
471
+ async def get_mounted_servers(self) -> dict[str, Any]:
472
+ """Get list of currently mounted MCP servers."""
473
+ try:
474
+ mounted = await self.single_user_mcp.get_mounted_servers()
475
+ return {"mounted_servers": mounted}
476
+ except Exception as e:
477
+ log.error(f"Failed to get mounted servers: {e}")
478
+ raise HTTPException(
479
+ status_code=500,
480
+ detail=f"Failed to get mounted servers: {str(e)}",
481
+ ) from e
482
+
483
+ async def mount_server(self, server_name: str) -> dict[str, str]:
484
+ """Mount a specific MCP server."""
485
+ try:
486
+ server_config = self._find_server_config(server_name)
487
+ success = await self.single_user_mcp.mount_server(server_config)
488
+ if success:
489
+ return {"message": f"Server {server_name} mounted successfully"}
490
+ raise HTTPException(
491
+ status_code=500,
492
+ detail=f"Failed to mount server: {server_name}",
493
+ )
494
+ except HTTPException:
495
+ raise
496
+ except Exception as e:
497
+ raise self._handle_server_operation_error("mount", server_name, e) from e
498
+
499
+ async def unmount_server(self, server_name: str) -> dict[str, str]:
500
+ """Unmount a specific MCP server."""
501
+ try:
502
+ if server_name == "test-echo":
503
+ log.info("Special handling for test-echo server unmount")
504
+ _ = await self.single_user_mcp.unmount_server(server_name)
505
+ return {"message": f"Server {server_name} unmounted successfully"}
506
+ _ = await self.single_user_mcp.unmount_server(server_name)
507
+ return {"message": f"Server {server_name} unmounted successfully"}
508
+ except HTTPException:
509
+ raise
510
+ except Exception as e:
511
+ raise self._handle_server_operation_error("unmount", server_name, e) from e
512
+
513
+ async def get_sessions(self) -> dict[str, Any]:
514
+ """Return recent MCP session summaries from local SQLite.
515
+
516
+ Response shape:
517
+ {
518
+ "sessions": [
519
+ {
520
+ "session_id": str,
521
+ "correlation_id": str,
522
+ "tool_calls": list[dict[str, Any]],
523
+ "data_access_summary": dict[str, Any]
524
+ },
525
+ ...
526
+ ]
527
+ }
528
+ """
529
+ try:
530
+ with create_db_session() as db_session:
531
+ # Fetch latest 100 sessions by primary key desc
532
+ results = (
533
+ db_session.query(MCPSessionModel)
534
+ .order_by(MCPSessionModel.id.desc())
535
+ .limit(100)
536
+ .all()
537
+ )
538
+
539
+ sessions: list[dict[str, Any]] = []
540
+ for row_model in results:
541
+ row = cast(Any, row_model)
542
+ tool_calls_val = row.tool_calls
543
+ data_access_summary_val = row.data_access_summary
544
+ sessions.append(
545
+ {
546
+ "session_id": row.session_id,
547
+ "correlation_id": row.correlation_id,
548
+ "tool_calls": tool_calls_val
549
+ if isinstance(tool_calls_val, list)
550
+ else [],
551
+ "data_access_summary": data_access_summary_val
552
+ if isinstance(data_access_summary_val, dict)
553
+ else {},
554
+ }
555
+ )
556
+
557
+ return {"sessions": sessions}
558
+ except Exception as e:
559
+ log.error(f"Failed to fetch sessions: {e}")
560
+ raise HTTPException(status_code=500, detail="Failed to fetch sessions") from e