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/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
|