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.
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
- # JSON files reside in the config directory
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
- # If missing and we ship a default in package root, bootstrap it
188
- if not target.exists():
204
+ if (not target.exists()) and repo_candidate.exists():
189
205
  try:
190
- pkg_default = Path(__file__).parent.parent / filename
191
- if pkg_default.exists():
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,,