open-edison 0.1.11__py3-none-any.whl → 0.1.15__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.11.dist-info → open_edison-0.1.15.dist-info}/METADATA +2 -2
- open_edison-0.1.15.dist-info/RECORD +18 -0
- src/frontend_dist/assets/index-_NTxjOfh.js +51 -0
- src/frontend_dist/assets/index-h6k8aL6h.css +1 -0
- src/frontend_dist/index.html +2 -2
- src/middleware/data_access_tracker.py +217 -133
- src/middleware/session_tracking.py +7 -0
- src/server.py +188 -7
- src/telemetry.py +23 -1
- open_edison-0.1.11.dist-info/RECORD +0 -18
- src/frontend_dist/assets/index-BPaXg1vr.js +0 -51
- src/frontend_dist/assets/index-BVdkI6ig.css +0 -1
- {open_edison-0.1.11.dist-info → open_edison-0.1.15.dist-info}/WHEEL +0 -0
- {open_edison-0.1.11.dist-info → open_edison-0.1.15.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.11.dist-info → open_edison-0.1.15.dist-info}/licenses/LICENSE +0 -0
src/server.py
CHANGED
@@ -6,6 +6,7 @@ No multi-user support, no complex routing - just a straightforward proxy.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import asyncio
|
9
|
+
import traceback
|
9
10
|
from collections.abc import Awaitable, Callable, Coroutine
|
10
11
|
from pathlib import Path
|
11
12
|
from typing import Any, cast
|
@@ -16,7 +17,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
16
17
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
17
18
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
18
19
|
from fastapi.staticfiles import StaticFiles
|
20
|
+
from fastmcp import FastMCP
|
19
21
|
from loguru import logger as log
|
22
|
+
from pydantic import BaseModel, Field
|
20
23
|
|
21
24
|
from src.config import MCPServerConfig, config
|
22
25
|
from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
|
@@ -178,21 +181,33 @@ class OpenEdisonProxy:
|
|
178
181
|
}
|
179
182
|
|
180
183
|
def _resolve_json_path(filename: str) -> Path:
|
181
|
-
|
184
|
+
"""
|
185
|
+
Resolve a JSON config file path consistently with src.config defaults.
|
186
|
+
|
187
|
+
Precedence for reads (and writes if chosen):
|
188
|
+
1) Repository root next to src/ (editable/dev) if file exists
|
189
|
+
2) Config dir (OPEN_EDISON_CONFIG_DIR or platform default)
|
190
|
+
- If missing, bootstrap from repo root default when available
|
191
|
+
3) Current working directory as last resort
|
192
|
+
"""
|
193
|
+
# 1) Prefer repository root next to src/
|
194
|
+
repo_candidate = Path(__file__).parent.parent / filename
|
195
|
+
if repo_candidate.exists():
|
196
|
+
return repo_candidate
|
197
|
+
|
198
|
+
# 2) Config directory
|
182
199
|
try:
|
183
200
|
base = _get_cfg_dir()
|
184
201
|
except Exception:
|
185
202
|
base = Path.cwd()
|
186
203
|
target = base / filename
|
187
|
-
|
188
|
-
if not target.exists():
|
204
|
+
if (not target.exists()) and repo_candidate.exists():
|
189
205
|
try:
|
190
|
-
|
191
|
-
|
192
|
-
target.write_text(pkg_default.read_text(encoding="utf-8"), encoding="utf-8")
|
206
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
207
|
+
target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
|
193
208
|
except Exception:
|
194
209
|
pass
|
195
|
-
return target
|
210
|
+
return target if target.exists() else repo_candidate
|
196
211
|
|
197
212
|
async def _serve_json(filename: str) -> Response: # type: ignore[override]
|
198
213
|
if filename not in allowed_json_files:
|
@@ -262,6 +277,18 @@ class OpenEdisonProxy:
|
|
262
277
|
|
263
278
|
return app
|
264
279
|
|
280
|
+
def _build_backend_config_top(
|
281
|
+
self, server_name: str, body: "OpenEdisonProxy._ValidateRequest"
|
282
|
+
) -> dict[str, Any]:
|
283
|
+
backend_entry: dict[str, Any] = {
|
284
|
+
"command": body.command,
|
285
|
+
"args": body.args,
|
286
|
+
"env": body.env or {},
|
287
|
+
}
|
288
|
+
if body.roots:
|
289
|
+
backend_entry["roots"] = body.roots
|
290
|
+
return {"mcpServers": {server_name: backend_entry}}
|
291
|
+
|
265
292
|
async def start(self) -> None:
|
266
293
|
"""Start the Open Edison proxy server"""
|
267
294
|
log.info("🚀 Starting Open Edison MCP Proxy Server")
|
@@ -343,6 +370,12 @@ class OpenEdisonProxy:
|
|
343
370
|
methods=["POST"],
|
344
371
|
dependencies=[Depends(self.verify_api_key)],
|
345
372
|
)
|
373
|
+
app.add_api_route(
|
374
|
+
"/mcp/validate",
|
375
|
+
self.validate_mcp_server,
|
376
|
+
methods=["POST"],
|
377
|
+
# Intentionally no auth required for validation for now
|
378
|
+
)
|
346
379
|
app.add_api_route(
|
347
380
|
"/mcp/{server_name}/stop",
|
348
381
|
self.stop_mcp_server,
|
@@ -565,3 +598,151 @@ class OpenEdisonProxy:
|
|
565
598
|
except Exception as e:
|
566
599
|
log.error(f"Failed to fetch sessions: {e}")
|
567
600
|
raise HTTPException(status_code=500, detail="Failed to fetch sessions") from e
|
601
|
+
|
602
|
+
# ---- MCP validation ----
|
603
|
+
class _ValidateRequest(BaseModel):
|
604
|
+
name: str | None = Field(None, description="Optional server name label")
|
605
|
+
command: str = Field(..., description="Executable to run, e.g. 'npx' or 'uvx'")
|
606
|
+
args: list[str] = Field(default_factory=list, description="Arguments to the command")
|
607
|
+
env: dict[str, str] | None = Field(
|
608
|
+
default=None,
|
609
|
+
description="Environment variables for the subprocess (values should already exist)",
|
610
|
+
)
|
611
|
+
roots: list[str] | None = Field(
|
612
|
+
default=None, description="Optional allowed roots for the MCP server"
|
613
|
+
)
|
614
|
+
timeout_s: float | None = Field(20.0, description="Overall timeout for validation")
|
615
|
+
|
616
|
+
async def validate_mcp_server(self, body: _ValidateRequest) -> dict[str, Any]: # noqa: C901
|
617
|
+
"""
|
618
|
+
Validate an MCP server by launching it via FastMCP and listing capabilities.
|
619
|
+
|
620
|
+
Returns tools, resources, and prompts if successful.
|
621
|
+
"""
|
622
|
+
|
623
|
+
server_name = body.name or "validation"
|
624
|
+
backend_cfg = self._build_backend_config_top(server_name, body)
|
625
|
+
|
626
|
+
log.info(
|
627
|
+
f"Validating MCP server command for '{server_name}': {body.command} {' '.join(body.args)}"
|
628
|
+
)
|
629
|
+
|
630
|
+
server: FastMCP[Any] | None = None
|
631
|
+
try:
|
632
|
+
# Guard for template entries with no command configured
|
633
|
+
if not body.command or not body.command.strip():
|
634
|
+
return {
|
635
|
+
"valid": False,
|
636
|
+
"error": "No command configured (template entry)",
|
637
|
+
"server": {
|
638
|
+
"name": server_name,
|
639
|
+
"command": body.command,
|
640
|
+
"args": body.args,
|
641
|
+
"has_roots": bool(body.roots),
|
642
|
+
},
|
643
|
+
}
|
644
|
+
|
645
|
+
server = FastMCP.as_proxy(
|
646
|
+
backend=backend_cfg, name=f"open-edison-validate-{server_name}"
|
647
|
+
)
|
648
|
+
tools, resources, prompts = await self._list_all_capabilities(server, body)
|
649
|
+
|
650
|
+
return {
|
651
|
+
"valid": True,
|
652
|
+
"server": {
|
653
|
+
"name": server_name,
|
654
|
+
"command": body.command,
|
655
|
+
"args": body.args,
|
656
|
+
"has_roots": bool(body.roots),
|
657
|
+
},
|
658
|
+
"tools": [self._safe_tool(t) for t in tools],
|
659
|
+
"resources": [self._safe_resource(r) for r in resources],
|
660
|
+
"prompts": [self._safe_prompt(p) for p in prompts],
|
661
|
+
}
|
662
|
+
except TimeoutError as te: # noqa: PERF203
|
663
|
+
log.error(f"MCP validation timed out: {te}\n{traceback.format_exc()}")
|
664
|
+
return {
|
665
|
+
"valid": False,
|
666
|
+
"error": "Validation timed out",
|
667
|
+
"server": {
|
668
|
+
"name": server_name,
|
669
|
+
"command": body.command,
|
670
|
+
"args": body.args,
|
671
|
+
"has_roots": bool(body.roots),
|
672
|
+
},
|
673
|
+
}
|
674
|
+
except Exception as e: # noqa: BLE001
|
675
|
+
log.error(f"MCP validation failed: {e}\n{traceback.format_exc()}")
|
676
|
+
return {
|
677
|
+
"valid": False,
|
678
|
+
"error": str(e),
|
679
|
+
"server": {
|
680
|
+
"name": server_name,
|
681
|
+
"command": body.command,
|
682
|
+
"args": body.args,
|
683
|
+
"has_roots": bool(body.roots),
|
684
|
+
},
|
685
|
+
}
|
686
|
+
finally:
|
687
|
+
# Best-effort cleanup if FastMCP exposes a shutdown/close
|
688
|
+
try:
|
689
|
+
if isinstance(server, FastMCP):
|
690
|
+
result = server.shutdown() # type: ignore[attr-defined]
|
691
|
+
# If it returns an awaitable, await it
|
692
|
+
if isinstance(result, Awaitable):
|
693
|
+
await result # type: ignore[func-returns-value]
|
694
|
+
except Exception as cleanup_err: # noqa: BLE001
|
695
|
+
log.debug(f"Validator cleanup skipped/failed: {cleanup_err}")
|
696
|
+
|
697
|
+
def _build_backend_config(
|
698
|
+
self, server_name: str, body: "OpenEdisonProxy._ValidateRequest"
|
699
|
+
) -> dict[str, Any]:
|
700
|
+
backend_entry: dict[str, Any] = {
|
701
|
+
"command": body.command,
|
702
|
+
"args": body.args,
|
703
|
+
"env": body.env or {},
|
704
|
+
}
|
705
|
+
if body.roots:
|
706
|
+
backend_entry["roots"] = body.roots
|
707
|
+
return {"mcpServers": {server_name: backend_entry}}
|
708
|
+
|
709
|
+
async def _list_all_capabilities(
|
710
|
+
self, server: FastMCP[Any], body: "OpenEdisonProxy._ValidateRequest"
|
711
|
+
) -> tuple[list[Any], list[Any], list[Any]]:
|
712
|
+
s: Any = server
|
713
|
+
|
714
|
+
async def _call_list(kind: str) -> list[Any]:
|
715
|
+
# Prefer public list_*; fallback to _list_* for proxies that expose private methods
|
716
|
+
for attr in (f"list_{kind}", f"_list_{kind}"):
|
717
|
+
if hasattr(s, attr):
|
718
|
+
method = getattr(s, attr)
|
719
|
+
return await method()
|
720
|
+
raise AttributeError(f"Proxy does not expose list method for {kind}")
|
721
|
+
|
722
|
+
async def list_all() -> tuple[list[Any], list[Any], list[Any]]:
|
723
|
+
tools = await _call_list("tools")
|
724
|
+
resources = await _call_list("resources")
|
725
|
+
prompts = await _call_list("prompts")
|
726
|
+
return tools, resources, prompts
|
727
|
+
|
728
|
+
timeout = body.timeout_s if isinstance(body.timeout_s, (int | float)) else 20.0
|
729
|
+
return await asyncio.wait_for(list_all(), timeout=timeout)
|
730
|
+
|
731
|
+
def _safe_tool(self, t: Any) -> dict[str, Any]:
|
732
|
+
name = getattr(t, "name", None)
|
733
|
+
description = getattr(t, "description", None)
|
734
|
+
return {"name": str(name) if name is not None else "", "description": description}
|
735
|
+
|
736
|
+
def _safe_resource(self, r: Any) -> dict[str, Any]:
|
737
|
+
uri = getattr(r, "uri", None)
|
738
|
+
try:
|
739
|
+
uri_str = str(uri) if uri is not None else ""
|
740
|
+
except Exception:
|
741
|
+
uri_str = ""
|
742
|
+
description = getattr(r, "description", None)
|
743
|
+
return {"uri": uri_str, "description": description}
|
744
|
+
|
745
|
+
def _safe_prompt(self, p: Any) -> dict[str, Any]:
|
746
|
+
name = getattr(p, "name", None)
|
747
|
+
description = getattr(p, "description", None)
|
748
|
+
return {"name": str(name) if name is not None else "", "description": description}
|
src/telemetry.py
CHANGED
@@ -50,6 +50,8 @@ _prompt_used_counter: Any | None = None
|
|
50
50
|
_private_data_access_counter: Any | None = None
|
51
51
|
_untrusted_public_data_counter: Any | None = None
|
52
52
|
_write_operation_counter: Any | None = None
|
53
|
+
_resource_access_blocked_counter: Any | None = None
|
54
|
+
_prompt_access_blocked_counter: Any | None = None
|
53
55
|
|
54
56
|
|
55
57
|
def _ensure_install_id() -> str:
|
@@ -116,7 +118,7 @@ def telemetry_recorder(func: Callable[P, R]) -> Callable[P, R | None]: # noqa:
|
|
116
118
|
return wrapper
|
117
119
|
|
118
120
|
|
119
|
-
def initialize_telemetry(override: TelemetryConfig | None = None) -> None:
|
121
|
+
def initialize_telemetry(override: TelemetryConfig | None = None) -> None: # noqa: C901
|
120
122
|
"""Initialize telemetry if enabled in config.
|
121
123
|
|
122
124
|
Safe to call multiple times; only first call initializes.
|
@@ -197,7 +199,9 @@ def initialize_telemetry(override: TelemetryConfig | None = None) -> None:
|
|
197
199
|
_servers_installed_gauge = meter.create_up_down_counter("servers_installed")
|
198
200
|
_tool_calls_metadata_counter = meter.create_counter("tool_calls_metadata")
|
199
201
|
_resource_used_counter = meter.create_counter("resource_used")
|
202
|
+
_resource_access_blocked_counter = meter.create_counter("resource_access_blocked")
|
200
203
|
_prompt_used_counter = meter.create_counter("prompt_used")
|
204
|
+
_prompt_access_blocked_counter = meter.create_counter("prompt_access_blocked")
|
201
205
|
_private_data_access_counter = meter.create_counter("private_data_access_calls")
|
202
206
|
_untrusted_public_data_counter = meter.create_counter("untrusted_public_data_calls")
|
203
207
|
_write_operation_counter = meter.create_counter("write_operation_calls")
|
@@ -280,6 +284,15 @@ def record_resource_used(resource_name: str) -> None:
|
|
280
284
|
_resource_used_counter.add(1, attributes=_common_attrs({"resource": resource_name}))
|
281
285
|
|
282
286
|
|
287
|
+
@telemetry_recorder
|
288
|
+
def record_resource_access_blocked(resource_name: str, reason: str) -> None:
|
289
|
+
if _resource_access_blocked_counter is None:
|
290
|
+
return
|
291
|
+
_resource_access_blocked_counter.add(
|
292
|
+
1, attributes=_common_attrs({"resource": resource_name, "reason": reason})
|
293
|
+
)
|
294
|
+
|
295
|
+
|
283
296
|
@telemetry_recorder
|
284
297
|
def record_prompt_used(prompt_name: str) -> None:
|
285
298
|
if _prompt_used_counter is None:
|
@@ -287,6 +300,15 @@ def record_prompt_used(prompt_name: str) -> None:
|
|
287
300
|
_prompt_used_counter.add(1, attributes=_common_attrs({"prompt": prompt_name}))
|
288
301
|
|
289
302
|
|
303
|
+
@telemetry_recorder
|
304
|
+
def record_prompt_access_blocked(prompt_name: str, reason: str) -> None:
|
305
|
+
if _prompt_access_blocked_counter is None:
|
306
|
+
return
|
307
|
+
_prompt_access_blocked_counter.add(
|
308
|
+
1, attributes=_common_attrs({"prompt": prompt_name, "reason": reason})
|
309
|
+
)
|
310
|
+
|
311
|
+
|
290
312
|
@telemetry_recorder
|
291
313
|
def record_private_data_access(source_type: str, name: str) -> None:
|
292
314
|
if _private_data_access_counter is None:
|
@@ -1,18 +0,0 @@
|
|
1
|
-
src/__init__.py,sha256=QWeZdjAm2D2B0eWhd8m2-DPpWvIP26KcNJxwEoU1oEQ,254
|
2
|
-
src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
|
3
|
-
src/cli.py,sha256=9cJN6mRvjbCcpTyTdUVl47J7OB7bxzSy0h8tfVbHuQU,9982
|
4
|
-
src/config.py,sha256=2a5rdImQmNGggL690PQprqZVsRUAJcdo8KS2Foj9N-U,9345
|
5
|
-
src/mcp_manager.py,sha256=VpRdVMy1WLegC-gBnyTcBMcKzQsdIn4JIWuHf7Q40hg,4442
|
6
|
-
src/server.py,sha256=IL0LA6k6b-r6hhpKXjqN3b3ZwvYLp0oneOx-r9DNNGs,23161
|
7
|
-
src/single_user_mcp.py,sha256=ue5UnC0nfmuLR4z87904WqH7B-0FaACFDWaBNNL7hXE,15259
|
8
|
-
src/telemetry.py,sha256=Rj8bk6DsJOvC_xNr07lkGIya4a95kt5Fnoo43B7qFWo,11061
|
9
|
-
src/frontend_dist/index.html,sha256=Zh5YmjkX9_Uy-pUpcLNvhvym4NazF6uA8MrMAT_cSIM,673
|
10
|
-
src/frontend_dist/assets/index-BPaXg1vr.js,sha256=BRGusr3m0yFM3wa3f_W81uKyCJmfmSQz249Fmt09FKA,232913
|
11
|
-
src/frontend_dist/assets/index-BVdkI6ig.css,sha256=gRr72wH_UhcHGoG-jSJG56j-QsRQekD3p2RkpCWfXtI,13921
|
12
|
-
src/middleware/data_access_tracker.py,sha256=D8ZeE1teR50glOeIPNTv9IIo8wfz8nDCXmIX4KrsfIM,21991
|
13
|
-
src/middleware/session_tracking.py,sha256=Q8g7DZN9pX6nONKB3rwdPXprBZL_ManGn8E49zJiBd8,19578
|
14
|
-
open_edison-0.1.11.dist-info/METADATA,sha256=sVFkwNbVZggGg0l5STE8Q0DWD_7pEYdn8aIPiG5o2HI,8967
|
15
|
-
open_edison-0.1.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
16
|
-
open_edison-0.1.11.dist-info/entry_points.txt,sha256=qNAkJcnoTXRhj8J--3PDmXz_TQKdB8H_0C9wiCtDIyA,72
|
17
|
-
open_edison-0.1.11.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
18
|
-
open_edison-0.1.11.dist-info/RECORD,,
|