open-edison 0.1.19__py3-none-any.whl → 0.1.26__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.19.dist-info → open_edison-0.1.26.dist-info}/METADATA +60 -41
- open_edison-0.1.26.dist-info/RECORD +17 -0
- src/cli.py +2 -1
- src/config.py +63 -51
- src/events.py +153 -0
- src/middleware/data_access_tracker.py +164 -434
- src/middleware/session_tracking.py +93 -29
- src/oauth_manager.py +281 -0
- src/permissions.py +292 -0
- src/server.py +484 -132
- src/single_user_mcp.py +221 -159
- src/telemetry.py +4 -40
- open_edison-0.1.19.dist-info/RECORD +0 -14
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/WHEEL +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.19.dist-info → open_edison-0.1.26.dist-info}/licenses/LICENSE +0 -0
src/server.py
CHANGED
@@ -9,36 +9,37 @@ import asyncio
|
|
9
9
|
import json
|
10
10
|
import traceback
|
11
11
|
from collections.abc import Awaitable, Callable, Coroutine
|
12
|
+
from contextlib import suppress
|
12
13
|
from pathlib import Path
|
13
|
-
from typing import Any, cast
|
14
|
+
from typing import Any, Literal, cast
|
14
15
|
|
15
16
|
import uvicorn
|
16
17
|
from fastapi import Depends, FastAPI, HTTPException, status
|
17
18
|
from fastapi.middleware.cors import CORSMiddleware
|
18
|
-
from fastapi.responses import
|
19
|
+
from fastapi.responses import (
|
20
|
+
FileResponse,
|
21
|
+
JSONResponse,
|
22
|
+
RedirectResponse,
|
23
|
+
Response,
|
24
|
+
StreamingResponse,
|
25
|
+
)
|
19
26
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
20
27
|
from fastapi.staticfiles import StaticFiles
|
21
28
|
from fastmcp import FastMCP
|
22
29
|
from loguru import logger as log
|
23
30
|
from pydantic import BaseModel, Field
|
24
31
|
|
25
|
-
from src
|
32
|
+
from src import events
|
33
|
+
from src.config import Config, MCPServerConfig
|
26
34
|
from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
|
27
35
|
from src.middleware.session_tracking import (
|
28
36
|
MCPSessionModel,
|
29
37
|
create_db_session,
|
30
38
|
)
|
39
|
+
from src.oauth_manager import OAuthStatus, get_oauth_manager
|
31
40
|
from src.single_user_mcp import SingleUserMCP
|
32
41
|
from src.telemetry import initialize_telemetry, set_servers_installed
|
33
42
|
|
34
|
-
|
35
|
-
def _get_current_config():
|
36
|
-
"""Get current config, allowing for test mocking."""
|
37
|
-
from src.config import config as current_config
|
38
|
-
|
39
|
-
return current_config
|
40
|
-
|
41
|
-
|
42
43
|
# Module-level dependency singletons
|
43
44
|
_security = HTTPBearer()
|
44
45
|
_auth_dependency = Depends(_security)
|
@@ -102,6 +103,15 @@ class OpenEdisonProxy:
|
|
102
103
|
StaticFiles(directory=str(assets_dir), html=False),
|
103
104
|
name="dashboard-assets",
|
104
105
|
)
|
106
|
+
# Serve service worker at root path for registration at /sw.js
|
107
|
+
sw_path = static_dir / "sw.js"
|
108
|
+
if sw_path.exists():
|
109
|
+
|
110
|
+
async def _sw() -> FileResponse: # type: ignore[override]
|
111
|
+
# Service workers must be served from the origin root scope
|
112
|
+
return FileResponse(str(sw_path), media_type="application/javascript")
|
113
|
+
|
114
|
+
app.add_api_route("/sw.js", _sw, methods=["GET"]) # type: ignore[arg-type]
|
105
115
|
favicon_path = static_dir / "favicon.ico"
|
106
116
|
if favicon_path.exists():
|
107
117
|
|
@@ -116,49 +126,29 @@ class OpenEdisonProxy:
|
|
116
126
|
log.warning(f"Failed to mount dashboard static assets: {mount_err}")
|
117
127
|
|
118
128
|
# Special-case: serve SQLite db and config JSONs for dashboard (prod replacement for Vite @fs)
|
119
|
-
def _resolve_db_path() -> Path
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
return db_path
|
127
|
-
# Check relative to config dir
|
128
|
-
try:
|
129
|
-
cfg_dir = _get_cfg_dir()
|
130
|
-
except Exception:
|
131
|
-
cfg_dir = Path.cwd()
|
132
|
-
rel1 = cfg_dir / db_path
|
133
|
-
if rel1.exists():
|
134
|
-
return rel1
|
135
|
-
# Also check relative to cwd as a fallback
|
136
|
-
rel2 = Path.cwd() / db_path
|
137
|
-
if rel2.exists():
|
138
|
-
return rel2
|
139
|
-
except Exception:
|
140
|
-
pass
|
141
|
-
|
142
|
-
# Fallback common locations
|
129
|
+
def _resolve_db_path() -> Path:
|
130
|
+
# Try configured database path first
|
131
|
+
db_cfg = Config().logging.database_path
|
132
|
+
db_path = Path(db_cfg)
|
133
|
+
if db_path.is_absolute() and db_path.exists():
|
134
|
+
return db_path
|
135
|
+
# Check relative to config dir
|
143
136
|
try:
|
144
137
|
cfg_dir = _get_cfg_dir()
|
145
138
|
except Exception:
|
146
139
|
cfg_dir = Path.cwd()
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
return None
|
140
|
+
rel1 = cfg_dir / db_path
|
141
|
+
if rel1.exists():
|
142
|
+
return rel1
|
143
|
+
# Also check relative to cwd as a fallback
|
144
|
+
rel2 = Path.cwd() / db_path
|
145
|
+
if rel2.exists():
|
146
|
+
return rel2
|
147
|
+
|
148
|
+
raise FileNotFoundError(f"Database file not found at {db_path}")
|
157
149
|
|
158
150
|
async def _serve_db() -> FileResponse: # type: ignore[override]
|
159
151
|
db_file = _resolve_db_path()
|
160
|
-
if db_file is None:
|
161
|
-
raise HTTPException(status_code=404, detail="Database file not found")
|
162
152
|
return FileResponse(str(db_file), media_type="application/octet-stream")
|
163
153
|
|
164
154
|
# Provide multiple paths the SPA might attempt (both edison.db legacy and sessions.db canonical)
|
@@ -200,12 +190,10 @@ class OpenEdisonProxy:
|
|
200
190
|
# 2) Repository/package defaults next to src/
|
201
191
|
repo_candidate = Path(__file__).parent.parent / filename
|
202
192
|
if repo_candidate.exists():
|
203
|
-
# Bootstrap a copy into config dir when possible
|
204
|
-
|
193
|
+
# Bootstrap a copy into config dir when possible (best effort)
|
194
|
+
with suppress(Exception):
|
205
195
|
target.parent.mkdir(parents=True, exist_ok=True)
|
206
196
|
target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
|
207
|
-
except Exception:
|
208
|
-
pass
|
209
197
|
return target if target.exists() else repo_candidate
|
210
198
|
|
211
199
|
# 3) Fall back to config dir path (will be created on save)
|
@@ -268,6 +256,46 @@ class OpenEdisonProxy:
|
|
268
256
|
|
269
257
|
app.add_api_route("/__save_json__", _save_json, methods=["POST"]) # type: ignore[arg-type]
|
270
258
|
|
259
|
+
# SSE events endpoint
|
260
|
+
async def _events() -> StreamingResponse: # type: ignore[override]
|
261
|
+
queue = await events.subscribe()
|
262
|
+
return StreamingResponse(
|
263
|
+
events.sse_stream(queue),
|
264
|
+
media_type="text/event-stream",
|
265
|
+
)
|
266
|
+
|
267
|
+
app.add_api_route("/events", _events, methods=["GET"]) # type: ignore[arg-type]
|
268
|
+
|
269
|
+
# Approval endpoint to allow an item for the rest of the session
|
270
|
+
class _ApprovalBody(BaseModel):
|
271
|
+
session_id: str
|
272
|
+
kind: Literal["tool", "resource", "prompt"]
|
273
|
+
name: str
|
274
|
+
|
275
|
+
async def _approve(body: _ApprovalBody) -> dict[str, Any]: # type: ignore[override]
|
276
|
+
try:
|
277
|
+
# Mark approval once; no persistent overrides
|
278
|
+
await events.approve_once(body.session_id, body.kind, body.name)
|
279
|
+
|
280
|
+
# Notify listeners (best effort, log failure)
|
281
|
+
events.fire_and_forget(
|
282
|
+
{
|
283
|
+
"type": "mcp_approved_once",
|
284
|
+
"session_id": body.session_id,
|
285
|
+
"kind": body.kind,
|
286
|
+
"name": body.name,
|
287
|
+
}
|
288
|
+
)
|
289
|
+
|
290
|
+
return {"status": "ok"}
|
291
|
+
except HTTPException:
|
292
|
+
raise
|
293
|
+
except Exception as e: # noqa: BLE001
|
294
|
+
log.error(f"Approval failed: {e}")
|
295
|
+
raise HTTPException(status_code=500, detail="Failed to approve item") from e
|
296
|
+
|
297
|
+
app.add_api_route("/api/approve", _approve, methods=["POST"]) # type: ignore[arg-type]
|
298
|
+
|
271
299
|
# Catch-all for @fs patterns; serve known db and json filenames
|
272
300
|
async def _serve_fs_path(rest: str): # type: ignore[override]
|
273
301
|
target = rest.strip("/")
|
@@ -282,6 +310,12 @@ class OpenEdisonProxy:
|
|
282
310
|
app.add_api_route("/@fs/{rest:path}", _serve_fs_path, methods=["GET"]) # type: ignore[arg-type]
|
283
311
|
app.add_api_route("/%40fs/{rest:path}", _serve_fs_path, methods=["GET"]) # type: ignore[arg-type]
|
284
312
|
|
313
|
+
# Redirect root to dashboard
|
314
|
+
async def _root_redirect() -> RedirectResponse: # type: ignore[override]
|
315
|
+
return RedirectResponse(url="/dashboard")
|
316
|
+
|
317
|
+
app.add_api_route("/", _root_redirect, methods=["GET"]) # type: ignore[arg-type]
|
318
|
+
|
285
319
|
return app
|
286
320
|
|
287
321
|
def _build_backend_config_top(
|
@@ -315,7 +349,7 @@ class OpenEdisonProxy:
|
|
315
349
|
await self.single_user_mcp.initialize()
|
316
350
|
|
317
351
|
# Emit snapshot of enabled servers
|
318
|
-
enabled_count = len([s for s in
|
352
|
+
enabled_count = len([s for s in Config().mcp_servers if s.enabled])
|
319
353
|
set_servers_installed(enabled_count)
|
320
354
|
|
321
355
|
# Add CORS middleware to FastAPI
|
@@ -335,7 +369,7 @@ class OpenEdisonProxy:
|
|
335
369
|
app=self.fastapi_app,
|
336
370
|
host=self.host,
|
337
371
|
port=self.port + 1,
|
338
|
-
log_level=
|
372
|
+
log_level=Config().logging.level.lower(),
|
339
373
|
)
|
340
374
|
fastapi_server = uvicorn.Server(fastapi_config)
|
341
375
|
servers_to_run.append(fastapi_server.serve())
|
@@ -346,7 +380,7 @@ class OpenEdisonProxy:
|
|
346
380
|
app=mcp_app,
|
347
381
|
host=self.host,
|
348
382
|
port=self.port,
|
349
|
-
log_level=
|
383
|
+
log_level=Config().logging.level.lower(),
|
350
384
|
)
|
351
385
|
fastmcp_server = uvicorn.Server(fastmcp_config)
|
352
386
|
servers_to_run.append(fastmcp_server.serve())
|
@@ -364,6 +398,12 @@ class OpenEdisonProxy:
|
|
364
398
|
self.mcp_status,
|
365
399
|
methods=["GET"],
|
366
400
|
)
|
401
|
+
# Endpoint to notify server that permissions JSONs changed; invalidate caches
|
402
|
+
app.add_api_route(
|
403
|
+
"/api/permissions-changed",
|
404
|
+
self.permissions_changed,
|
405
|
+
methods=["POST"],
|
406
|
+
)
|
367
407
|
app.add_api_route(
|
368
408
|
"/mcp/validate",
|
369
409
|
self.validate_mcp_server,
|
@@ -382,17 +422,55 @@ class OpenEdisonProxy:
|
|
382
422
|
methods=["POST"],
|
383
423
|
dependencies=[Depends(self.verify_api_key)],
|
384
424
|
)
|
425
|
+
app.add_api_route(
|
426
|
+
"/mcp/mount/{server_name}",
|
427
|
+
self.mount_mcp_server,
|
428
|
+
methods=["POST"],
|
429
|
+
dependencies=[Depends(self.verify_api_key)],
|
430
|
+
)
|
431
|
+
app.add_api_route(
|
432
|
+
"/mcp/mount/{server_name}",
|
433
|
+
self.unmount_mcp_server,
|
434
|
+
methods=["DELETE"],
|
435
|
+
dependencies=[Depends(self.verify_api_key)],
|
436
|
+
)
|
385
437
|
# Public sessions endpoint (no auth) for simple local dashboard
|
386
438
|
app.add_api_route(
|
387
439
|
"/sessions",
|
388
440
|
self.get_sessions,
|
389
441
|
methods=["GET"],
|
390
442
|
)
|
391
|
-
|
443
|
+
|
444
|
+
# OAuth endpoints
|
445
|
+
app.add_api_route(
|
446
|
+
"/mcp/oauth/status",
|
447
|
+
self.get_oauth_status_all,
|
448
|
+
methods=["GET"],
|
449
|
+
dependencies=[Depends(self.verify_api_key)],
|
450
|
+
)
|
451
|
+
app.add_api_route(
|
452
|
+
"/mcp/oauth/status/{server_name}",
|
453
|
+
self.get_oauth_status,
|
454
|
+
methods=["GET"],
|
455
|
+
dependencies=[Depends(self.verify_api_key)],
|
456
|
+
)
|
392
457
|
app.add_api_route(
|
393
|
-
"/
|
394
|
-
self.
|
458
|
+
"/mcp/oauth/test-connection/{server_name}",
|
459
|
+
self.oauth_test_connection,
|
395
460
|
methods=["POST"],
|
461
|
+
dependencies=[Depends(self.verify_api_key)],
|
462
|
+
)
|
463
|
+
app.add_api_route(
|
464
|
+
"/mcp/oauth/tokens/{server_name}",
|
465
|
+
self.oauth_clear_tokens,
|
466
|
+
methods=["DELETE"],
|
467
|
+
dependencies=[Depends(self.verify_api_key)],
|
468
|
+
)
|
469
|
+
app.add_api_route(
|
470
|
+
"/mcp/oauth/refresh/{server_name}",
|
471
|
+
self.oauth_refresh_status,
|
472
|
+
methods=["POST"],
|
473
|
+
dependencies=[Depends(self.verify_api_key)],
|
396
474
|
)
|
397
475
|
|
398
476
|
async def verify_api_key(
|
@@ -403,11 +481,27 @@ class OpenEdisonProxy:
|
|
403
481
|
|
404
482
|
Returns the API key string if valid, otherwise raises HTTPException.
|
405
483
|
"""
|
406
|
-
|
407
|
-
if credentials.credentials != current_config.server.api_key:
|
484
|
+
if credentials.credentials != Config().server.api_key:
|
408
485
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
409
486
|
return credentials.credentials
|
410
487
|
|
488
|
+
async def permissions_changed(self) -> dict[str, Any]:
|
489
|
+
"""Invalidate SingleUserMCP manager caches after permissions JSON changed.
|
490
|
+
|
491
|
+
This attempts to clear any known cache methods on the internal managers and then
|
492
|
+
warms the lists to ensure subsequent list calls reflect current state.
|
493
|
+
"""
|
494
|
+
try:
|
495
|
+
mcp = self.single_user_mcp
|
496
|
+
# Warm managers so any internal caches are refreshed
|
497
|
+
await mcp._tool_manager.list_tools() # type: ignore[attr-defined]
|
498
|
+
await mcp._resource_manager.list_resources() # type: ignore[attr-defined]
|
499
|
+
await mcp._prompt_manager.list_prompts() # type: ignore[attr-defined]
|
500
|
+
return {"status": "ok"}
|
501
|
+
except Exception as e: # noqa: BLE001
|
502
|
+
log.error(f"Failed to process permissions-changed: {e}")
|
503
|
+
raise HTTPException(status_code=500, detail="Failed to invalidate caches") from e
|
504
|
+
|
411
505
|
async def mcp_status(self) -> dict[str, list[dict[str, Any]]]:
|
412
506
|
"""Get status of configured MCP servers (auth required)."""
|
413
507
|
return {
|
@@ -416,34 +510,13 @@ class OpenEdisonProxy:
|
|
416
510
|
"name": server.name,
|
417
511
|
"enabled": server.enabled,
|
418
512
|
}
|
419
|
-
for server in
|
513
|
+
for server in Config().mcp_servers
|
420
514
|
]
|
421
515
|
}
|
422
516
|
|
423
|
-
def _handle_server_operation_error(
|
424
|
-
self, operation: str, server_name: str, error: Exception
|
425
|
-
) -> HTTPException:
|
426
|
-
"""Handle common server operation errors."""
|
427
|
-
log.error(f"Failed to {operation} server {server_name}: {error}")
|
428
|
-
return HTTPException(
|
429
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
430
|
-
detail=f"Failed to {operation} server: {str(error)}",
|
431
|
-
)
|
432
|
-
|
433
|
-
def _find_server_config(self, server_name: str) -> MCPServerConfig:
|
434
|
-
"""Find server configuration by name."""
|
435
|
-
current_config = _get_current_config()
|
436
|
-
for config_server in current_config.mcp_servers:
|
437
|
-
if config_server.name == server_name:
|
438
|
-
return config_server
|
439
|
-
raise HTTPException(
|
440
|
-
status_code=404,
|
441
|
-
detail=f"Server configuration not found: {server_name}",
|
442
|
-
)
|
443
|
-
|
444
517
|
async def health_check(self) -> dict[str, Any]:
|
445
518
|
"""Health check endpoint"""
|
446
|
-
return {"status": "healthy", "version": "0.1.0", "mcp_servers": len(
|
519
|
+
return {"status": "healthy", "version": "0.1.0", "mcp_servers": len(Config().mcp_servers)}
|
447
520
|
|
448
521
|
async def get_mounted_servers(self) -> dict[str, Any]:
|
449
522
|
"""Get list of currently mounted MCP servers."""
|
@@ -458,48 +531,64 @@ class OpenEdisonProxy:
|
|
458
531
|
) from e
|
459
532
|
|
460
533
|
async def reinitialize_mcp_servers(self) -> dict[str, Any]:
|
461
|
-
"""Reinitialize all MCP servers by creating a fresh instance and reloading config.
|
462
|
-
|
534
|
+
"""Reinitialize all MCP servers by creating a fresh instance and reloading config.
|
535
|
+
|
536
|
+
Returns a JSON payload summarizing the final mounted servers so callers can display status.
|
537
|
+
"""
|
463
538
|
try:
|
464
539
|
log.info("🔄 Reinitializing MCP servers via API endpoint")
|
465
540
|
|
466
|
-
# Reload configuration from disk
|
467
|
-
log.info("Reloading configuration from disk")
|
468
|
-
from src.config import Config
|
469
|
-
|
470
|
-
fresh_config = Config.load()
|
471
|
-
log.info("✅ Configuration reloaded from disk")
|
472
|
-
|
473
541
|
# Create a completely new SingleUserMCP instance to ensure clean state
|
474
542
|
old_mcp = self.single_user_mcp
|
475
543
|
self.single_user_mcp = SingleUserMCP()
|
544
|
+
del old_mcp
|
476
545
|
|
477
546
|
# Initialize the new instance with fresh config
|
478
|
-
await self.single_user_mcp.initialize(
|
547
|
+
await self.single_user_mcp.initialize()
|
479
548
|
|
480
|
-
#
|
481
|
-
|
549
|
+
# Summarize final mounted servers
|
550
|
+
try:
|
551
|
+
mounted = await self.single_user_mcp.get_mounted_servers()
|
552
|
+
except Exception:
|
553
|
+
log.error("Failed to get mounted servers")
|
554
|
+
mounted = []
|
482
555
|
|
483
|
-
|
484
|
-
|
485
|
-
"
|
486
|
-
"
|
487
|
-
"
|
556
|
+
names = [m.get("name", "") for m in mounted]
|
557
|
+
return {
|
558
|
+
"status": "ok",
|
559
|
+
"total_final_mounted": len(mounted),
|
560
|
+
"mounted_servers": names,
|
488
561
|
}
|
489
562
|
|
490
|
-
log.info("✅ MCP servers reinitialized successfully via API")
|
491
|
-
return result
|
492
|
-
|
493
563
|
except Exception as e:
|
494
564
|
log.error(f"❌ Failed to reinitialize MCP servers: {e}")
|
495
|
-
# Restore the old instance on failure
|
496
|
-
if old_mcp is not None:
|
497
|
-
self.single_user_mcp = old_mcp
|
498
565
|
raise HTTPException(
|
499
566
|
status_code=500,
|
500
567
|
detail=f"Failed to reinitialize MCP servers: {str(e)}",
|
501
568
|
) from e
|
502
569
|
|
570
|
+
async def mount_mcp_server(self, server_name: str) -> dict[str, Any]:
|
571
|
+
"""Mount a single MCP server by name (auth required)."""
|
572
|
+
try:
|
573
|
+
ok = await self.single_user_mcp.mount_server(server_name)
|
574
|
+
return {"mounted": bool(ok), "server": server_name}
|
575
|
+
except Exception as e:
|
576
|
+
log.error(f"❌ Failed to mount server {server_name}: {e}")
|
577
|
+
raise HTTPException(
|
578
|
+
status_code=500, detail=f"Failed to mount server {server_name}: {str(e)}"
|
579
|
+
) from e
|
580
|
+
|
581
|
+
async def unmount_mcp_server(self, server_name: str) -> dict[str, Any]:
|
582
|
+
"""Unmount a previously mounted MCP server by name (auth required)."""
|
583
|
+
try:
|
584
|
+
ok = await self.single_user_mcp.unmount(server_name)
|
585
|
+
return {"unmounted": bool(ok), "server": server_name}
|
586
|
+
except Exception as e:
|
587
|
+
log.error(f"❌ Failed to unmount server {server_name}: {e}")
|
588
|
+
raise HTTPException(
|
589
|
+
status_code=500, detail=f"Failed to unmount server {server_name}: {str(e)}"
|
590
|
+
) from e
|
591
|
+
|
503
592
|
async def get_sessions(self) -> dict[str, Any]:
|
504
593
|
"""Return recent MCP session summaries from local SQLite.
|
505
594
|
|
@@ -549,21 +638,6 @@ class OpenEdisonProxy:
|
|
549
638
|
log.error(f"Failed to fetch sessions: {e}")
|
550
639
|
raise HTTPException(status_code=500, detail="Failed to fetch sessions") from e
|
551
640
|
|
552
|
-
async def clear_caches(self) -> dict[str, str]:
|
553
|
-
"""Clear all permission caches to force reload from configuration files."""
|
554
|
-
try:
|
555
|
-
from src.middleware.data_access_tracker import clear_all_permissions_caches
|
556
|
-
|
557
|
-
log.info("🔄 Clearing all permission caches via API endpoint")
|
558
|
-
clear_all_permissions_caches()
|
559
|
-
log.info("✅ All permission caches cleared successfully")
|
560
|
-
|
561
|
-
return {"status": "success", "message": "All permission caches cleared"}
|
562
|
-
except Exception as e:
|
563
|
-
log.error(f"❌ Failed to clear permission caches: {e}")
|
564
|
-
# Don't raise HTTPException - allow to fail gracefully as requested
|
565
|
-
return {"status": "error", "message": f"Failed to clear caches: {str(e)}"}
|
566
|
-
|
567
641
|
# ---- MCP validation ----
|
568
642
|
class _ValidateRequest(BaseModel):
|
569
643
|
name: str | None = Field(None, description="Optional server name label")
|
@@ -659,18 +733,6 @@ class OpenEdisonProxy:
|
|
659
733
|
except Exception as cleanup_err: # noqa: BLE001
|
660
734
|
log.debug(f"Validator cleanup skipped/failed: {cleanup_err}")
|
661
735
|
|
662
|
-
def _build_backend_config(
|
663
|
-
self, server_name: str, body: "OpenEdisonProxy._ValidateRequest"
|
664
|
-
) -> dict[str, Any]:
|
665
|
-
backend_entry: dict[str, Any] = {
|
666
|
-
"command": body.command,
|
667
|
-
"args": body.args,
|
668
|
-
"env": body.env or {},
|
669
|
-
}
|
670
|
-
if body.roots:
|
671
|
-
backend_entry["roots"] = body.roots
|
672
|
-
return {"mcpServers": {server_name: backend_entry}}
|
673
|
-
|
674
736
|
async def _list_all_capabilities(
|
675
737
|
self, server: FastMCP[Any], body: "OpenEdisonProxy._ValidateRequest"
|
676
738
|
) -> tuple[list[Any], list[Any], list[Any]]:
|
@@ -717,3 +779,293 @@ class OpenEdisonProxy:
|
|
717
779
|
"name": prefix + "_" + str(name) if name is not None else "",
|
718
780
|
"description": description,
|
719
781
|
}
|
782
|
+
|
783
|
+
# ---- OAuth endpoints ----
|
784
|
+
|
785
|
+
async def get_oauth_status_all(self) -> dict[str, Any]:
|
786
|
+
"""Get OAuth status for all configured MCP servers."""
|
787
|
+
try:
|
788
|
+
oauth_manager = get_oauth_manager()
|
789
|
+
|
790
|
+
servers_info = {}
|
791
|
+
for server_config in Config().mcp_servers:
|
792
|
+
server_name = server_config.name
|
793
|
+
info = oauth_manager.get_server_info(server_name)
|
794
|
+
|
795
|
+
if info:
|
796
|
+
# Use cached OAuth info
|
797
|
+
servers_info[server_name] = {
|
798
|
+
"server_name": info.server_name,
|
799
|
+
"status": info.status.value,
|
800
|
+
"error_message": info.error_message,
|
801
|
+
"token_expires_at": info.token_expires_at,
|
802
|
+
"has_refresh_token": info.has_refresh_token,
|
803
|
+
"scopes": info.scopes,
|
804
|
+
}
|
805
|
+
else:
|
806
|
+
# OAuth status not checked yet - check proactively for remote servers
|
807
|
+
if server_config.is_remote_server():
|
808
|
+
remote_url = server_config.get_remote_url()
|
809
|
+
log.info(f"🔍 Proactively checking OAuth for remote server {server_name}")
|
810
|
+
|
811
|
+
# Check OAuth requirements for this remote server
|
812
|
+
oauth_info = await oauth_manager.check_oauth_requirement(
|
813
|
+
server_name, remote_url
|
814
|
+
)
|
815
|
+
|
816
|
+
servers_info[server_name] = {
|
817
|
+
"server_name": oauth_info.server_name,
|
818
|
+
"status": oauth_info.status.value,
|
819
|
+
"error_message": oauth_info.error_message,
|
820
|
+
"token_expires_at": oauth_info.token_expires_at,
|
821
|
+
"has_refresh_token": oauth_info.has_refresh_token,
|
822
|
+
"scopes": oauth_info.scopes,
|
823
|
+
}
|
824
|
+
else:
|
825
|
+
# Local server - no OAuth needed
|
826
|
+
servers_info[server_name] = {
|
827
|
+
"server_name": server_name,
|
828
|
+
"status": OAuthStatus.NOT_REQUIRED.value,
|
829
|
+
"error_message": None,
|
830
|
+
"token_expires_at": None,
|
831
|
+
"has_refresh_token": False,
|
832
|
+
"scopes": None,
|
833
|
+
}
|
834
|
+
|
835
|
+
return {"oauth_status": servers_info}
|
836
|
+
|
837
|
+
except Exception as e:
|
838
|
+
log.error(f"Failed to get OAuth status for all servers: {e}")
|
839
|
+
raise HTTPException(
|
840
|
+
status_code=500,
|
841
|
+
detail=f"Failed to get OAuth status: {str(e)}",
|
842
|
+
) from e
|
843
|
+
|
844
|
+
def _find_server_config(self, server_name: str) -> MCPServerConfig:
|
845
|
+
"""Find server configuration by name."""
|
846
|
+
for config_server in Config().mcp_servers:
|
847
|
+
if config_server.name == server_name:
|
848
|
+
return config_server
|
849
|
+
raise HTTPException(
|
850
|
+
status_code=404,
|
851
|
+
detail=f"Server configuration not found: {server_name}",
|
852
|
+
)
|
853
|
+
|
854
|
+
async def get_oauth_status(self, server_name: str) -> dict[str, Any]:
|
855
|
+
"""Get OAuth status for a specific MCP server."""
|
856
|
+
try:
|
857
|
+
server_config = self._find_server_config(server_name)
|
858
|
+
oauth_manager = get_oauth_manager()
|
859
|
+
|
860
|
+
# Get the remote URL if this is a remote server
|
861
|
+
remote_url = server_config.get_remote_url()
|
862
|
+
|
863
|
+
# Check or refresh OAuth status
|
864
|
+
oauth_info = await oauth_manager.check_oauth_requirement(server_name, remote_url)
|
865
|
+
|
866
|
+
return {
|
867
|
+
"server_name": oauth_info.server_name,
|
868
|
+
"mcp_url": oauth_info.mcp_url,
|
869
|
+
"status": oauth_info.status.value,
|
870
|
+
"error_message": oauth_info.error_message,
|
871
|
+
"token_expires_at": oauth_info.token_expires_at,
|
872
|
+
"has_refresh_token": oauth_info.has_refresh_token,
|
873
|
+
"scopes": oauth_info.scopes,
|
874
|
+
"client_name": oauth_info.client_name,
|
875
|
+
}
|
876
|
+
|
877
|
+
except HTTPException:
|
878
|
+
raise
|
879
|
+
except Exception as e:
|
880
|
+
log.error(f"Failed to get OAuth status for {server_name}: {e}")
|
881
|
+
raise HTTPException(
|
882
|
+
status_code=500,
|
883
|
+
detail=f"Failed to get OAuth status: {str(e)}",
|
884
|
+
) from e
|
885
|
+
|
886
|
+
class _OAuthAuthorizeRequest(BaseModel):
|
887
|
+
scopes: list[str] | None = Field(None, description="OAuth scopes to request")
|
888
|
+
client_name: str | None = Field(None, description="Client name for OAuth registration")
|
889
|
+
|
890
|
+
async def oauth_test_connection(
|
891
|
+
self, server_name: str, body: _OAuthAuthorizeRequest | None = None
|
892
|
+
) -> dict[str, Any]:
|
893
|
+
"""
|
894
|
+
Test connection to a remote MCP server, triggering OAuth flow if needed.
|
895
|
+
|
896
|
+
This endpoint creates a temporary FastMCP client with OAuth authentication
|
897
|
+
and attempts to make a connection. This automatically triggers FastMCP's
|
898
|
+
OAuth flow, which will open a browser for user authorization.
|
899
|
+
"""
|
900
|
+
try:
|
901
|
+
server_config = self._find_server_config(server_name)
|
902
|
+
oauth_manager = get_oauth_manager()
|
903
|
+
|
904
|
+
# Check if this is a remote server
|
905
|
+
if not server_config.is_remote_server():
|
906
|
+
raise HTTPException(
|
907
|
+
status_code=400,
|
908
|
+
detail=f"Server {server_name} is a local server and does not support OAuth",
|
909
|
+
)
|
910
|
+
|
911
|
+
# Get the remote URL
|
912
|
+
remote_url = server_config.get_remote_url()
|
913
|
+
if not remote_url:
|
914
|
+
raise HTTPException(
|
915
|
+
status_code=400, detail=f"Server {server_name} does not have a valid remote URL"
|
916
|
+
)
|
917
|
+
|
918
|
+
# Get OAuth configuration
|
919
|
+
scopes = None
|
920
|
+
client_name = None
|
921
|
+
|
922
|
+
if body:
|
923
|
+
scopes = body.scopes
|
924
|
+
client_name = body.client_name
|
925
|
+
|
926
|
+
# Use server config OAuth settings if not provided in request
|
927
|
+
if not scopes and server_config.oauth_scopes:
|
928
|
+
scopes = server_config.oauth_scopes
|
929
|
+
if not client_name and server_config.oauth_client_name:
|
930
|
+
client_name = server_config.oauth_client_name
|
931
|
+
|
932
|
+
log.info(f"🔗 Testing connection to {server_name} at {remote_url}")
|
933
|
+
|
934
|
+
# Import FastMCP client for testing
|
935
|
+
from fastmcp import Client as FastMCPClient
|
936
|
+
from fastmcp.client.auth import OAuth
|
937
|
+
|
938
|
+
# Create OAuth auth object
|
939
|
+
oauth = OAuth(
|
940
|
+
mcp_url=remote_url,
|
941
|
+
scopes=scopes,
|
942
|
+
client_name=client_name or "OpenEdison MCP Gateway",
|
943
|
+
token_storage_cache_dir=oauth_manager.cache_dir,
|
944
|
+
)
|
945
|
+
|
946
|
+
# Create a temporary client and test the connection
|
947
|
+
# This will automatically trigger OAuth flow if tokens don't exist
|
948
|
+
try:
|
949
|
+
async with FastMCPClient(remote_url, auth=oauth) as client:
|
950
|
+
# Try to ping the server - this triggers OAuth if needed
|
951
|
+
log.info(
|
952
|
+
f"🔐 Attempting to connect to {server_name} (may open browser for OAuth)..."
|
953
|
+
)
|
954
|
+
await client.ping()
|
955
|
+
log.info(f"✅ Successfully connected to {server_name}")
|
956
|
+
|
957
|
+
# Update OAuth status in manager
|
958
|
+
await oauth_manager.check_oauth_requirement(server_name, remote_url)
|
959
|
+
|
960
|
+
return {
|
961
|
+
"status": "connection_successful",
|
962
|
+
"message": f"Successfully connected to {server_name}. OAuth tokens are now cached.",
|
963
|
+
"server_name": server_name,
|
964
|
+
}
|
965
|
+
|
966
|
+
except Exception as e:
|
967
|
+
log.error(f"❌ Failed to connect to {server_name}: {e}")
|
968
|
+
|
969
|
+
# Check if this was an OAuth-related error
|
970
|
+
error_message = str(e)
|
971
|
+
if "oauth" in error_message.lower() or "authorization" in error_message.lower():
|
972
|
+
return {
|
973
|
+
"status": "oauth_required",
|
974
|
+
"message": f"OAuth authorization completed for {server_name}. Please try connecting again.",
|
975
|
+
"server_name": server_name,
|
976
|
+
}
|
977
|
+
raise HTTPException(
|
978
|
+
status_code=500, detail=f"Connection test failed: {error_message}"
|
979
|
+
) from None
|
980
|
+
|
981
|
+
except HTTPException:
|
982
|
+
raise
|
983
|
+
except Exception as e:
|
984
|
+
log.error(f"Failed to test connection for {server_name}: {e}")
|
985
|
+
raise HTTPException(
|
986
|
+
status_code=500,
|
987
|
+
detail=f"Failed to test connection: {str(e)}",
|
988
|
+
) from e
|
989
|
+
|
990
|
+
async def oauth_clear_tokens(self, server_name: str) -> dict[str, Any]:
|
991
|
+
"""Clear stored OAuth tokens for a server."""
|
992
|
+
try:
|
993
|
+
server_config = self._find_server_config(server_name)
|
994
|
+
oauth_manager = get_oauth_manager()
|
995
|
+
|
996
|
+
# Check if this is a remote server
|
997
|
+
if not server_config.is_remote_server():
|
998
|
+
raise HTTPException(
|
999
|
+
status_code=400,
|
1000
|
+
detail=f"Server {server_name} is a local server and does not support OAuth",
|
1001
|
+
)
|
1002
|
+
|
1003
|
+
# Get the remote URL
|
1004
|
+
remote_url = server_config.get_remote_url()
|
1005
|
+
if not remote_url:
|
1006
|
+
raise HTTPException(
|
1007
|
+
status_code=400, detail=f"Server {server_name} does not have a valid remote URL"
|
1008
|
+
)
|
1009
|
+
|
1010
|
+
success = oauth_manager.clear_tokens(server_name, remote_url)
|
1011
|
+
|
1012
|
+
if success:
|
1013
|
+
return {
|
1014
|
+
"status": "success",
|
1015
|
+
"message": f"OAuth tokens cleared for {server_name}",
|
1016
|
+
"server_name": server_name,
|
1017
|
+
}
|
1018
|
+
raise HTTPException(
|
1019
|
+
status_code=500, detail=f"Failed to clear OAuth tokens for {server_name}"
|
1020
|
+
)
|
1021
|
+
|
1022
|
+
except HTTPException:
|
1023
|
+
raise
|
1024
|
+
except Exception as e:
|
1025
|
+
log.error(f"Failed to clear OAuth tokens for {server_name}: {e}")
|
1026
|
+
raise HTTPException(
|
1027
|
+
status_code=500,
|
1028
|
+
detail=f"Failed to clear OAuth tokens: {str(e)}",
|
1029
|
+
) from e
|
1030
|
+
|
1031
|
+
async def oauth_refresh_status(self, server_name: str) -> dict[str, Any]:
|
1032
|
+
"""Refresh OAuth status for a server."""
|
1033
|
+
try:
|
1034
|
+
server_config = self._find_server_config(server_name)
|
1035
|
+
oauth_manager = get_oauth_manager()
|
1036
|
+
|
1037
|
+
# Check if this is a remote server
|
1038
|
+
if not server_config.is_remote_server():
|
1039
|
+
raise HTTPException(
|
1040
|
+
status_code=400,
|
1041
|
+
detail=f"Server {server_name} is a local server and does not support OAuth",
|
1042
|
+
)
|
1043
|
+
|
1044
|
+
# Get the remote URL (now guaranteed to be non-None for remote servers)
|
1045
|
+
remote_url = server_config.get_remote_url()
|
1046
|
+
if not remote_url:
|
1047
|
+
raise HTTPException(
|
1048
|
+
status_code=400, detail=f"Server {server_name} does not have a valid remote URL"
|
1049
|
+
)
|
1050
|
+
|
1051
|
+
# Refresh OAuth status
|
1052
|
+
oauth_info = await oauth_manager.refresh_server_status(server_name, remote_url)
|
1053
|
+
|
1054
|
+
return {
|
1055
|
+
"status": "refreshed",
|
1056
|
+
"server_name": oauth_info.server_name,
|
1057
|
+
"oauth_status": oauth_info.status.value,
|
1058
|
+
"error_message": oauth_info.error_message,
|
1059
|
+
"token_expires_at": oauth_info.token_expires_at,
|
1060
|
+
"has_refresh_token": oauth_info.has_refresh_token,
|
1061
|
+
"scopes": oauth_info.scopes,
|
1062
|
+
}
|
1063
|
+
|
1064
|
+
except HTTPException:
|
1065
|
+
raise
|
1066
|
+
except Exception as e:
|
1067
|
+
log.error(f"Failed to refresh OAuth status for {server_name}: {e}")
|
1068
|
+
raise HTTPException(
|
1069
|
+
status_code=500,
|
1070
|
+
detail=f"Failed to refresh OAuth status: {str(e)}",
|
1071
|
+
) from e
|