open-edison 0.1.10__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.
@@ -29,6 +29,11 @@ from sqlalchemy.sql import select
29
29
 
30
30
  from src.config import get_config_dir # type: ignore[reportMissingImports]
31
31
  from src.middleware.data_access_tracker import DataAccessTracker
32
+ from src.telemetry import (
33
+ record_prompt_used,
34
+ record_resource_used,
35
+ record_tool_call,
36
+ )
32
37
 
33
38
 
34
39
  @dataclass
@@ -165,6 +170,13 @@ def get_session_from_db(session_id: str) -> MCPSession:
165
170
  data_access_tracker.has_external_communication = trifecta.get(
166
171
  "has_external_communication", False
167
172
  )
173
+ # Restore ACL highest level if present
174
+ if isinstance(summary_data, dict) and "acl" in summary_data:
175
+ acl_summary: Any = summary_data.get("acl") # type: ignore
176
+ if isinstance(acl_summary, dict):
177
+ highest = acl_summary.get("highest_acl_level") # type: ignore
178
+ if isinstance(highest, str) and highest:
179
+ data_access_tracker.highest_acl_level = highest
168
180
 
169
181
  return MCPSession(
170
182
  session_id=session_id,
@@ -285,6 +297,8 @@ class SessionTrackingMiddleware(Middleware):
285
297
  assert session.data_access_tracker is not None
286
298
  log.debug(f"🔍 Analyzing tool {context.message.name} for security implications")
287
299
  _ = session.data_access_tracker.add_tool_call(context.message.name)
300
+ # Telemetry: record tool call
301
+ record_tool_call(context.message.name)
288
302
 
289
303
  # Update database session
290
304
  with create_db_session() as db_session:
@@ -383,6 +397,7 @@ class SessionTrackingMiddleware(Middleware):
383
397
 
384
398
  log.debug(f"🔍 Analyzing resource {resource_name} for security implications")
385
399
  _ = session.data_access_tracker.add_resource_access(resource_name)
400
+ record_resource_used(resource_name)
386
401
 
387
402
  # Update database session
388
403
  with create_db_session() as db_session:
@@ -463,6 +478,7 @@ class SessionTrackingMiddleware(Middleware):
463
478
 
464
479
  log.debug(f"🔍 Analyzing prompt {prompt_name} for security implications")
465
480
  _ = session.data_access_tracker.add_prompt_access(prompt_name)
481
+ record_prompt_used(prompt_name)
466
482
 
467
483
  # Update database session
468
484
  with create_db_session() as db_session:
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]
@@ -26,6 +29,7 @@ from src.middleware.session_tracking import (
26
29
  create_db_session,
27
30
  )
28
31
  from src.single_user_mcp import SingleUserMCP
32
+ from src.telemetry import initialize_telemetry, set_servers_installed
29
33
 
30
34
 
31
35
  def _get_current_config():
@@ -177,21 +181,33 @@ class OpenEdisonProxy:
177
181
  }
178
182
 
179
183
  def _resolve_json_path(filename: str) -> Path:
180
- # 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
181
199
  try:
182
200
  base = _get_cfg_dir()
183
201
  except Exception:
184
202
  base = Path.cwd()
185
203
  target = base / filename
186
- # If missing and we ship a default in package root, bootstrap it
187
- if not target.exists():
204
+ if (not target.exists()) and repo_candidate.exists():
188
205
  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")
206
+ target.parent.mkdir(parents=True, exist_ok=True)
207
+ target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
192
208
  except Exception:
193
209
  pass
194
- return target
210
+ return target if target.exists() else repo_candidate
195
211
 
196
212
  async def _serve_json(filename: str) -> Response: # type: ignore[override]
197
213
  if filename not in allowed_json_files:
@@ -261,12 +277,26 @@ class OpenEdisonProxy:
261
277
 
262
278
  return app
263
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
+
264
292
  async def start(self) -> None:
265
293
  """Start the Open Edison proxy server"""
266
294
  log.info("🚀 Starting Open Edison MCP Proxy Server")
267
295
  log.info(f"FastAPI management API on {self.host}:{self.port + 1}")
268
296
  log.info(f"FastMCP protocol server on {self.host}:{self.port}")
269
297
 
298
+ initialize_telemetry()
299
+
270
300
  # Ensure the sessions database exists and has the required schema
271
301
  try:
272
302
  with create_db_session():
@@ -277,6 +307,10 @@ class OpenEdisonProxy:
277
307
  # Initialize the FastMCP server (this handles starting enabled MCP servers)
278
308
  await self.single_user_mcp.initialize()
279
309
 
310
+ # Emit snapshot of enabled servers
311
+ enabled_count = len([s for s in config.mcp_servers if s.enabled])
312
+ set_servers_installed(enabled_count)
313
+
280
314
  # Add CORS middleware to FastAPI
281
315
  self.fastapi_app.add_middleware(
282
316
  CORSMiddleware,
@@ -336,6 +370,12 @@ class OpenEdisonProxy:
336
370
  methods=["POST"],
337
371
  dependencies=[Depends(self.verify_api_key)],
338
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
+ )
339
379
  app.add_api_route(
340
380
  "/mcp/{server_name}/stop",
341
381
  self.stop_mcp_server,
@@ -558,3 +598,151 @@ class OpenEdisonProxy:
558
598
  except Exception as e:
559
599
  log.error(f"Failed to fetch sessions: {e}")
560
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 ADDED
@@ -0,0 +1,336 @@
1
+ """
2
+ Telemetry for Open Edison (opt-out).
3
+ This module provides a thin, optional wrapper around OpenTelemetry to export
4
+ basic usage metrics to an OTLP endpoint. If telemetry is disabled or the
5
+ OpenTelemetry packages are not installed, all functions are safe no-ops.
6
+
7
+ Events/metrics captured (high level, install-unique ID for deaggregation):
8
+ - tool_calls_total (counter)
9
+ - tool_calls_blocked_total (counter)
10
+ - servers_installed_total (up-down counter / gauge)
11
+ - tool_calls_metadata_total (counter)
12
+ - resource_used_total (counter)
13
+ - prompt_used_total (counter)
14
+ - private_data_access_calls_total (counter)
15
+ - untrusted_public_data_calls_total (counter)
16
+ - write_operation_calls_total (counter)
17
+
18
+ Configuration: see `TelemetryConfig` in `src.config`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import traceback
26
+ import uuid
27
+ from collections.abc import Callable
28
+ from typing import Any, ParamSpec, TypeVar
29
+
30
+ from loguru import logger as log
31
+
32
+ # OpenTelemetry metrics components
33
+ from opentelemetry import metrics as ot_metrics
34
+ from opentelemetry.exporter.otlp.proto.http import metric_exporter as otlp_metric_exporter
35
+ from opentelemetry.sdk import metrics as ot_sdk_metrics
36
+ from opentelemetry.sdk.metrics import export as ot_metrics_export
37
+ from opentelemetry.sdk.resources import Resource # type: ignore[reportMissingTypeStubs]
38
+
39
+ from src.config import TelemetryConfig, config, get_config_dir
40
+
41
+ _initialized: bool = False
42
+ _install_id: str | None = None
43
+ _provider: Any | None = None
44
+ _tool_calls_counter: Any | None = None
45
+ _tool_calls_blocked_counter: Any | None = None
46
+ _servers_installed_gauge: Any | None = None
47
+ _tool_calls_metadata_counter: Any | None = None
48
+ _resource_used_counter: Any | None = None
49
+ _prompt_used_counter: Any | None = None
50
+ _private_data_access_counter: Any | None = None
51
+ _untrusted_public_data_counter: Any | None = None
52
+ _write_operation_counter: Any | None = None
53
+ _resource_access_blocked_counter: Any | None = None
54
+ _prompt_access_blocked_counter: Any | None = None
55
+
56
+
57
+ def _ensure_install_id() -> str:
58
+ """Create or read a persistent install-unique ID under the config dir."""
59
+ global _install_id
60
+ if _install_id:
61
+ return _install_id
62
+ try:
63
+ cfg_dir = get_config_dir()
64
+ cfg_dir.mkdir(parents=True, exist_ok=True)
65
+ except Exception: # noqa: BLE001
66
+ log.error(
67
+ "Could not resolve or create config dir for install_id; using ephemeral ID\n{}",
68
+ traceback.format_exc(),
69
+ )
70
+ _install_id = str(uuid.uuid4())
71
+ return _install_id
72
+
73
+ id_file = cfg_dir / "install_id"
74
+ if id_file.exists():
75
+ try:
76
+ _install_id = id_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4())
77
+ except Exception: # noqa: BLE001
78
+ log.error(
79
+ "Failed reading install_id file; using ephemeral ID\n{}",
80
+ traceback.format_exc(),
81
+ )
82
+ _install_id = str(uuid.uuid4())
83
+ else:
84
+ _install_id = str(uuid.uuid4())
85
+ try:
86
+ id_file.write_text(_install_id, encoding="utf-8")
87
+ except Exception: # noqa: BLE001
88
+ log.error(
89
+ "Failed writing install_id file; continuing without persistence\n{}",
90
+ traceback.format_exc(),
91
+ )
92
+ return _install_id
93
+
94
+
95
+ def _telemetry_enabled() -> bool:
96
+ tel_cfg = config.telemetry or TelemetryConfig()
97
+ return bool(tel_cfg.enabled)
98
+
99
+
100
+ P = ParamSpec("P")
101
+ R = TypeVar("R")
102
+
103
+
104
+ def telemetry_recorder(func: Callable[P, R]) -> Callable[P, R | None]: # noqa: UP047
105
+ """No-op when disabled, ensure init, and catch/log failures."""
106
+
107
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: # type: ignore[override]
108
+ if not _telemetry_enabled():
109
+ return None
110
+ if not _initialized:
111
+ initialize_telemetry()
112
+ try:
113
+ return func(*args, **kwargs)
114
+ except Exception: # noqa: BLE001
115
+ log.error("Telemetry emit failed\n{}", traceback.format_exc())
116
+ return None
117
+
118
+ return wrapper
119
+
120
+
121
+ def initialize_telemetry(override: TelemetryConfig | None = None) -> None: # noqa: C901
122
+ """Initialize telemetry if enabled in config.
123
+
124
+ Safe to call multiple times; only first call initializes.
125
+ """
126
+ global \
127
+ _initialized, \
128
+ _provider, \
129
+ _tool_calls_counter, \
130
+ _tool_calls_blocked_counter, \
131
+ _servers_installed_gauge
132
+
133
+ if _initialized:
134
+ return
135
+
136
+ telemetry_cfg = override if override is not None else (config.telemetry or TelemetryConfig())
137
+ if not telemetry_cfg.enabled:
138
+ log.debug("Telemetry disabled by config")
139
+ _initialized = True
140
+ return
141
+
142
+ # Exporter
143
+ exporter_kwargs: dict[str, Any] = {}
144
+ if telemetry_cfg.otlp_endpoint:
145
+ exporter_kwargs["endpoint"] = telemetry_cfg.otlp_endpoint
146
+ # Allow environment variables to provide endpoint when not set in config
147
+ env_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or os.environ.get(
148
+ "OTEL_EXPORTER_OTLP_ENDPOINT"
149
+ )
150
+ if "endpoint" not in exporter_kwargs and env_endpoint:
151
+ exporter_kwargs["endpoint"] = env_endpoint
152
+ # If no endpoint is available from config or env, skip initialization quietly
153
+ if "endpoint" not in exporter_kwargs:
154
+ log.debug("No OTLP endpoint configured (config or env); skipping telemetry init")
155
+ _initialized = True
156
+ return
157
+ if telemetry_cfg.headers:
158
+ exporter_kwargs["headers"] = telemetry_cfg.headers
159
+
160
+ try:
161
+ exporter: Any = otlp_metric_exporter.OTLPMetricExporter(**exporter_kwargs)
162
+ except Exception: # noqa: BLE001
163
+ log.error("OTLP exporter init failed\n{}", traceback.format_exc())
164
+ return
165
+
166
+ # Reader
167
+ try:
168
+ reader: Any = ot_metrics_export.PeriodicExportingMetricReader(
169
+ exporter=exporter,
170
+ export_interval_millis=max(1000, telemetry_cfg.export_interval_ms),
171
+ )
172
+ except Exception: # noqa: BLE001
173
+ log.error("OTLP reader init failed\n{}", traceback.format_exc())
174
+ return
175
+
176
+ # Provider/meter
177
+ try:
178
+ # Attach a resource so metrics include service identifiers
179
+ resource = Resource.create(
180
+ {
181
+ "service.name": "open-edison",
182
+ "service.namespace": "open-edison",
183
+ "telemetry.sdk.language": "python",
184
+ }
185
+ )
186
+ provider: Any = ot_sdk_metrics.MeterProvider(metric_readers=[reader], resource=resource)
187
+ _provider = provider
188
+ ot_metrics.set_meter_provider(provider)
189
+ meter: Any = ot_metrics.get_meter("open-edison")
190
+ except Exception: # noqa: BLE001
191
+ log.error("Metrics provider init failed\n{}", traceback.format_exc())
192
+ return
193
+
194
+ # Instruments
195
+ try:
196
+ # Do not suffix counters with _total; Prometheus exporter appends it
197
+ _tool_calls_counter = meter.create_counter("tool_calls")
198
+ _tool_calls_blocked_counter = meter.create_counter("tool_calls_blocked")
199
+ _servers_installed_gauge = meter.create_up_down_counter("servers_installed")
200
+ _tool_calls_metadata_counter = meter.create_counter("tool_calls_metadata")
201
+ _resource_used_counter = meter.create_counter("resource_used")
202
+ _resource_access_blocked_counter = meter.create_counter("resource_access_blocked")
203
+ _prompt_used_counter = meter.create_counter("prompt_used")
204
+ _prompt_access_blocked_counter = meter.create_counter("prompt_access_blocked")
205
+ _private_data_access_counter = meter.create_counter("private_data_access_calls")
206
+ _untrusted_public_data_counter = meter.create_counter("untrusted_public_data_calls")
207
+ _write_operation_counter = meter.create_counter("write_operation_calls")
208
+ except Exception: # noqa: BLE001
209
+ log.error("Metrics instrument creation failed\n{}", traceback.format_exc())
210
+ return
211
+
212
+ _ = _ensure_install_id()
213
+ _initialized = True
214
+ log.info("📈 Telemetry initialized")
215
+
216
+
217
+ def force_flush_metrics(timeout_ms: int = 5000) -> bool:
218
+ """Force-flush metrics synchronously if a provider is initialized.
219
+
220
+ Returns True on success, False otherwise.
221
+ """
222
+ try:
223
+ provider = _provider
224
+ if provider is None:
225
+ return False
226
+ # Some providers expose force_flush(timeout_millis=...), others as force_flush() -> bool
227
+ if hasattr(provider, "force_flush"):
228
+ try:
229
+ # Try with timeout argument first
230
+ result = provider.force_flush(timeout_millis=timeout_ms) # type: ignore[misc]
231
+ except TypeError:
232
+ result = provider.force_flush()
233
+ return bool(result)
234
+ return False
235
+ except Exception: # noqa: BLE001
236
+ log.error("Force flush failed\n{}", traceback.format_exc())
237
+ return False
238
+
239
+
240
+ def _common_attrs(extra: dict[str, Any] | None = None) -> dict[str, Any]:
241
+ attrs: dict[str, Any] = {"install_id": _ensure_install_id(), "app": "open-edison"}
242
+ if extra:
243
+ attrs.update(extra)
244
+ return attrs
245
+
246
+
247
+ @telemetry_recorder
248
+ def record_tool_call(tool_name: str) -> None:
249
+ if _tool_calls_counter is None:
250
+ return
251
+ _tool_calls_counter.add(1, attributes=_common_attrs({"tool": tool_name}))
252
+
253
+
254
+ @telemetry_recorder
255
+ def record_tool_call_blocked(tool_name: str, reason: str) -> None:
256
+ if _tool_calls_blocked_counter is None:
257
+ return
258
+ _tool_calls_blocked_counter.add(
259
+ 1, attributes=_common_attrs({"tool": tool_name, "reason": reason})
260
+ )
261
+
262
+
263
+ @telemetry_recorder
264
+ def record_tool_call_metadata(tool_name: str, metadata: dict[str, Any]) -> None:
265
+ if _tool_calls_metadata_counter is None:
266
+ return
267
+ metadata_str = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
268
+ _tool_calls_metadata_counter.add(
269
+ 1, attributes=_common_attrs({"tool": tool_name, "metadata_json": metadata_str})
270
+ )
271
+
272
+
273
+ @telemetry_recorder
274
+ def set_servers_installed(count: int) -> None:
275
+ if _servers_installed_gauge is None:
276
+ return
277
+ _servers_installed_gauge.add(count, attributes=_common_attrs({"state": "snapshot"}))
278
+
279
+
280
+ @telemetry_recorder
281
+ def record_resource_used(resource_name: str) -> None:
282
+ if _resource_used_counter is None:
283
+ return
284
+ _resource_used_counter.add(1, attributes=_common_attrs({"resource": resource_name}))
285
+
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
+
296
+ @telemetry_recorder
297
+ def record_prompt_used(prompt_name: str) -> None:
298
+ if _prompt_used_counter is None:
299
+ return
300
+ _prompt_used_counter.add(1, attributes=_common_attrs({"prompt": prompt_name}))
301
+
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
+
312
+ @telemetry_recorder
313
+ def record_private_data_access(source_type: str, name: str) -> None:
314
+ if _private_data_access_counter is None:
315
+ return
316
+ _private_data_access_counter.add(
317
+ 1, attributes=_common_attrs({"source_type": source_type, "name": name})
318
+ )
319
+
320
+
321
+ @telemetry_recorder
322
+ def record_untrusted_public_data(source_type: str, name: str) -> None:
323
+ if _untrusted_public_data_counter is None:
324
+ return
325
+ _untrusted_public_data_counter.add(
326
+ 1, attributes=_common_attrs({"source_type": source_type, "name": name})
327
+ )
328
+
329
+
330
+ @telemetry_recorder
331
+ def record_write_operation(source_type: str, name: str) -> None:
332
+ if _write_operation_counter is None:
333
+ return
334
+ _write_operation_counter.add(
335
+ 1, attributes=_common_attrs({"source_type": source_type, "name": name})
336
+ )
@@ -1,17 +0,0 @@
1
- src/__init__.py,sha256=QWeZdjAm2D2B0eWhd8m2-DPpWvIP26KcNJxwEoU1oEQ,254
2
- src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
3
- src/cli.py,sha256=ketV-e9oQMVlLBjZR7YbK33XkEfqxPyzWqYkS1YwqYc,9968
4
- src/config.py,sha256=klWrNycPxzVt9wPhiNbjXMkB4bHZplenfWDx-3UtQac,7120
5
- src/mcp_manager.py,sha256=VpRdVMy1WLegC-gBnyTcBMcKzQsdIn4JIWuHf7Q40hg,4442
6
- src/server.py,sha256=7hwhutP0qZ_mjZfs6jcB-UNe_VyibFKl6hPyHWoa-ns,22896
7
- src/single_user_mcp.py,sha256=ue5UnC0nfmuLR4z87904WqH7B-0FaACFDWaBNNL7hXE,15259
8
- src/frontend_dist/index.html,sha256=CL9uiDUygp5_5_VpsW4WMgYFsMAfVSueYit_vFgX0Qo,673
9
- src/frontend_dist/assets/index-CKkid2y-.js,sha256=zaZ7j0nyGkywXAMuCrhZLaSOVqLu7JkQG3wE_8QiFT4,219537
10
- src/frontend_dist/assets/index-CRxojymD.css,sha256=kANM9zPkbS5aLrPzePZK0Fbt580I6kNnyFjkFH13HtA,11383
11
- src/middleware/data_access_tracker.py,sha256=JkwZdtMCiVU7JJZDd-GhlowW2szMDnXrD95nhxQVXR4,21165
12
- src/middleware/session_tracking.py,sha256=rWZh4UBQbqzPh4p6vxdtRwEC1uzq93yjzxcI9LnlRkA,19307
13
- open_edison-0.1.10.dist-info/METADATA,sha256=15i5EIVlRNQtBIs3RJTTwiTPXEfF2FYy2a3W2KoBN3g,8834
14
- open_edison-0.1.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- open_edison-0.1.10.dist-info/entry_points.txt,sha256=qNAkJcnoTXRhj8J--3PDmXz_TQKdB8H_0C9wiCtDIyA,72
16
- open_edison-0.1.10.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
- open_edison-0.1.10.dist-info/RECORD,,