open-edison 0.1.64__py3-none-any.whl → 0.1.72rc1__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.
@@ -10,8 +10,8 @@
10
10
  const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
11
11
  document.documentElement.setAttribute('data-theme', prefersLight ? 'light' : 'dark');
12
12
  </script>
13
- <script type="module" crossorigin src="/assets/index-BUUcUfTt.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-o6_8mdM8.css">
13
+ <script type="module" crossorigin src="/assets/index-D6ziuTsl.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-D05VN_1l.css">
15
15
  </head>
16
16
 
17
17
  <body>
src/frontend_dist/sw.js CHANGED
@@ -61,8 +61,28 @@ self.addEventListener('notificationclick', (event) => {
61
61
  return;
62
62
  }
63
63
 
64
- // Generic click: open dashboard as a safe fallback
65
- event.waitUntil(self.clients.openWindow('/dashboard').catch(() => { }));
64
+ // Generic click: focus existing dashboard tab; if not found, open one with URL params so it can enqueue the pending approval
65
+ event.waitUntil((async () => {
66
+ try {
67
+ const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
68
+ const base = self.location && self.location.origin ? self.location.origin : '';
69
+ const targetPrefix = base + '/dashboard';
70
+ const existing = allClients.find(c => c.url && c.url.startsWith(targetPrefix));
71
+ if (existing) {
72
+ try { existing.postMessage({ type: 'MCP_ENQUEUE_PENDING', data: payload }); } catch (e) { /* ignore */ }
73
+ await existing.focus();
74
+ return;
75
+ }
76
+ } catch (e) { /* ignore */ }
77
+ try {
78
+ const params = new URLSearchParams();
79
+ if (payload.sessionId) params.set('pa_s', payload.sessionId);
80
+ if (payload.kind) params.set('pa_k', payload.kind);
81
+ if (payload.name) params.set('pa_n', payload.name);
82
+ const url = '/dashboard/?' + params.toString();
83
+ await self.clients.openWindow(url);
84
+ } catch (e) { /* ignore */ }
85
+ })());
66
86
  } catch (e) {
67
87
  // swallow
68
88
  }
@@ -300,7 +300,7 @@ def export_to_vscode(
300
300
 
301
301
  # Build the minimal config
302
302
  new_config: dict[str, Any] = {
303
- "mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
303
+ "servers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
304
304
  }
305
305
 
306
306
  # If already configured exactly as desired and not forcing, no-op
@@ -0,0 +1,144 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager, suppress
6
+ from typing import TextIO
7
+
8
+ import anyio
9
+ from anyio import to_thread
10
+ from loguru import logger as log
11
+
12
+ # Track active stderr pipe endpoints so we can force-close on shutdown
13
+ _ACTIVE_PIPES: set[tuple[TextIO, TextIO]] = set()
14
+ _shutting_down: bool = False
15
+
16
+
17
+ def install_stdio_client_stderr_capture() -> None: # noqa: C901
18
+ """
19
+ Monkeypatch mcp.client.stdio.stdio_client so child process stderr is
20
+ routed to our logger at trace level with a stable prefix.
21
+
22
+ If an explicit errlog is provided by the caller, we respect it and do not capture.
23
+ """
24
+ try:
25
+ from mcp.client import stdio as _mcp_stdio
26
+ except Exception as e: # noqa: BLE001
27
+ log.debug(f"stdio capture: MCP stdio not available: {e}")
28
+ return
29
+
30
+ _original_stdio_client = _mcp_stdio.stdio_client
31
+
32
+ @asynccontextmanager
33
+ async def _edison_stdio_client( # noqa: C901
34
+ server: _mcp_stdio.StdioServerParameters, errlog: TextIO | None = sys.stderr
35
+ ) -> AsyncIterator[tuple[object, object]]:
36
+ # Respect non-default errlog
37
+ if errlog is not None and errlog is not sys.stderr:
38
+ async with _original_stdio_client(server, errlog=errlog) as transport:
39
+ yield transport
40
+ return
41
+
42
+ # Create a pipe for stderr capture
43
+ read_fd, write_fd = os.pipe()
44
+ read_fp = os.fdopen(
45
+ read_fd,
46
+ "r",
47
+ buffering=1,
48
+ encoding=server.encoding,
49
+ errors=server.encoding_error_handler,
50
+ )
51
+ write_fp = os.fdopen(
52
+ write_fd,
53
+ "w",
54
+ buffering=1,
55
+ encoding=server.encoding,
56
+ errors=server.encoding_error_handler,
57
+ )
58
+ _ACTIVE_PIPES.add((read_fp, write_fp))
59
+
60
+ if sys.platform != "win32":
61
+ # POSIX: integrate with event loop, avoid background thread
62
+ os.set_blocking(read_fd, False)
63
+ loop = asyncio.get_running_loop()
64
+ buffer = b""
65
+
66
+ def _on_readable() -> None:
67
+ nonlocal buffer
68
+ try:
69
+ chunk = os.read(read_fd, 8192)
70
+ except Exception:
71
+ with suppress(Exception):
72
+ loop.remove_reader(read_fd)
73
+ return
74
+ if not chunk:
75
+ with suppress(Exception):
76
+ loop.remove_reader(read_fd)
77
+ return
78
+ buffer += chunk
79
+ while True:
80
+ try:
81
+ idx = buffer.index(b"\n")
82
+ except ValueError:
83
+ break
84
+ line = buffer[:idx]
85
+ buffer = buffer[idx + 1 :]
86
+ if not _shutting_down:
87
+ try:
88
+ text = line.decode(
89
+ server.encoding, errors=server.encoding_error_handler
90
+ )
91
+ except Exception:
92
+ text = line.decode(errors="replace")
93
+ log.trace(f"TOOL PROCESS STDERR {text.rstrip()}")
94
+
95
+ loop.add_reader(read_fd, _on_readable)
96
+ try:
97
+ async with _original_stdio_client(server, errlog=write_fp) as transport:
98
+ yield transport
99
+ finally:
100
+ with suppress(Exception):
101
+ loop.remove_reader(read_fd)
102
+ with suppress(Exception):
103
+ write_fp.close()
104
+ with suppress(Exception):
105
+ read_fp.close()
106
+ with suppress(Exception):
107
+ _ACTIVE_PIPES.discard((read_fp, write_fp))
108
+ else:
109
+
110
+ async def _stderr_reader() -> None:
111
+ try:
112
+ while True:
113
+ line = await to_thread.run_sync(read_fp.readline)
114
+ if not line:
115
+ break
116
+ if not _shutting_down:
117
+ log.trace(f"TOOL PROCESS STDERR {line.rstrip()}")
118
+ except Exception as e: # noqa: BLE001
119
+ log.debug(f"stderr monitor stopped: {e}")
120
+ finally:
121
+ with suppress(Exception):
122
+ read_fp.close()
123
+ with suppress(Exception):
124
+ _ACTIVE_PIPES.discard((read_fp, write_fp))
125
+
126
+ async with anyio.create_task_group() as tg:
127
+ tg.start_soon(_stderr_reader)
128
+ try:
129
+ async with _original_stdio_client(server, errlog=write_fp) as transport:
130
+ yield transport
131
+ finally:
132
+ with suppress(Exception):
133
+ write_fp.close()
134
+ with suppress(Exception):
135
+ read_fp.close()
136
+ tg.cancel_scope.cancel()
137
+ with suppress(Exception):
138
+ _ACTIVE_PIPES.discard((read_fp, write_fp))
139
+
140
+ try:
141
+ _mcp_stdio.stdio_client = _edison_stdio_client # type: ignore[assignment]
142
+ log.debug("stdio capture: installed stdio_client monkeypatch")
143
+ except Exception as e: # noqa: BLE001
144
+ log.debug(f"stdio capture: failed to install monkeypatch: {e}")
@@ -14,10 +14,12 @@ names (with server-name/path prefixes) to their security classifications:
14
14
 
15
15
  from dataclasses import dataclass
16
16
  from typing import Any
17
+ from urllib.parse import urlparse
17
18
 
18
19
  from loguru import logger as log
19
20
 
20
21
  from src import events
22
+ from src.config import Config
21
23
  from src.permissions import (
22
24
  ACL_RANK,
23
25
  Permissions,
@@ -109,6 +111,45 @@ class DataAccessTracker:
109
111
  log.info(f"✍️ Write operation detected via {source_type}: {name}")
110
112
  record_write_operation(source_type, name)
111
113
 
114
+ @staticmethod
115
+ def _server_name_from_resource_uri(resource_uri: str) -> str:
116
+ """Extract the mounted server name from a FastMCP-prefixed resource URI.
117
+
118
+ FastMCP prefixes resources by inserting the mount prefix as the authority
119
+ (e.g., scheme://prefix/...) or as the first path segment. We try both and
120
+ fall back to "builtin" (local server) if no known prefix is found.
121
+ """
122
+ try:
123
+ server_names = {s.name for s in Config().mcp_servers}
124
+
125
+ parsed = urlparse(resource_uri)
126
+ # Primary: authority/netloc holds the prefix in typical FastMCP URIs
127
+ if parsed.netloc and parsed.netloc in server_names:
128
+ return parsed.netloc
129
+
130
+ # Secondary: first path segment may hold the prefix
131
+ path = parsed.path or ""
132
+ if path.startswith("/"):
133
+ path = path[1:]
134
+ first_segment = path.split("/", 1)[0] if path else ""
135
+ if first_segment and first_segment in server_names:
136
+ return first_segment
137
+ except Exception:
138
+ pass
139
+ return "builtin"
140
+
141
+ @staticmethod
142
+ def _server_name_from_prompt_name(prompt_name: str) -> str:
143
+ """Extract mounted server name from a prompt name using FastMCP prefixing.
144
+
145
+ Prompts are exposed with prefixed names (similar to tools), e.g. "prefix_name".
146
+ We leverage existing logic for tools to determine the server name.
147
+ """
148
+ try:
149
+ return Permissions.server_name_from_tool_name(prompt_name)
150
+ except Exception:
151
+ return "builtin"
152
+
112
153
  def add_tool_call(self, tool_name: str):
113
154
  """
114
155
  Add a tool call and update trifecta flags based on tool classification.
@@ -222,8 +263,10 @@ class DataAccessTracker:
222
263
  perms = Permissions()
223
264
  permissions = perms.get_resource_permission(resource_name)
224
265
 
225
- # Check if resource is enabled
226
- if not perms.is_resource_enabled(resource_name):
266
+ # Check if resource is enabled and server is enabled via resource-specific resolution
267
+ server_name = self._server_name_from_resource_uri(resource_name)
268
+ server_enabled = Permissions.is_server_enabled(server_name)
269
+ if not (permissions.enabled and server_enabled):
227
270
  log.warning(f"🚫 BLOCKING resource access {resource_name} - resource is disabled")
228
271
  record_resource_access_blocked(resource_name, "disabled")
229
272
  events.fire_and_forget(
@@ -284,8 +327,10 @@ class DataAccessTracker:
284
327
  perms = Permissions()
285
328
  permissions = perms.get_prompt_permission(prompt_name)
286
329
 
287
- # Check if prompt is enabled
288
- if not perms.is_prompt_enabled(prompt_name):
330
+ # Check if prompt is enabled and server is enabled via prompt-specific resolution
331
+ server_name = self._server_name_from_prompt_name(prompt_name)
332
+ server_enabled = Permissions.is_server_enabled(server_name)
333
+ if not (permissions.enabled and server_enabled):
289
334
  log.warning(f"🚫 BLOCKING prompt access {prompt_name} - prompt is disabled")
290
335
  record_prompt_access_blocked(prompt_name, "disabled")
291
336
  events.fire_and_forget(
@@ -5,6 +5,7 @@ This middleware tracks tool usage patterns across all mounted tool calls,
5
5
  providing session-level statistics accessible via contextvar.
6
6
  """
7
7
 
8
+ import time
8
9
  import uuid
9
10
  from collections.abc import Generator
10
11
  from contextlib import contextmanager
@@ -15,6 +16,7 @@ from pathlib import Path
15
16
  from typing import Any, cast
16
17
 
17
18
  import mcp.types as mt
19
+ from fastmcp.exceptions import ToolError
18
20
  from fastmcp.prompts.prompt import FunctionPrompt
19
21
  from fastmcp.resources import FunctionResource
20
22
  from fastmcp.server.middleware import Middleware
@@ -140,7 +142,8 @@ def get_session_from_db(session_id: str) -> MCPSession:
140
142
  session_id=session_id,
141
143
  correlation_id=str(uuid.uuid4()),
142
144
  tool_calls=[], # type: ignore
143
- data_access_summary={}, # type: ignore
145
+ # Store session creation timestamp inside the summary to avoid schema changes
146
+ data_access_summary={"created_at": datetime.now().isoformat()}, # type: ignore
144
147
  )
145
148
  db_session.add(new_session_model)
146
149
  db_session.commit()
@@ -194,6 +197,41 @@ def get_session_from_db(session_id: str) -> MCPSession:
194
197
  )
195
198
 
196
199
 
200
+ def _persist_session_to_db(session: MCPSession) -> None:
201
+ """Serialize and persist the given session to the SQLite database."""
202
+ with create_db_session() as db_session:
203
+ db_session_model = db_session.execute(
204
+ select(MCPSessionModel).where(MCPSessionModel.session_id == session.session_id)
205
+ ).scalar_one()
206
+
207
+ tool_calls_dict = [
208
+ {
209
+ "id": tc.id,
210
+ "tool_name": tc.tool_name,
211
+ "parameters": tc.parameters,
212
+ "timestamp": tc.timestamp.isoformat(),
213
+ "duration_ms": tc.duration_ms,
214
+ "status": tc.status,
215
+ "result": tc.result,
216
+ }
217
+ for tc in session.tool_calls
218
+ ]
219
+ db_session_model.tool_calls = tool_calls_dict # type: ignore
220
+ # Merge existing summary with tracker dict so we preserve created_at and other keys
221
+ existing_summary: dict[str, Any] = {}
222
+ try:
223
+ if isinstance(db_session_model.data_access_summary, dict): # type: ignore
224
+ existing_summary = dict(db_session_model.data_access_summary) # type: ignore
225
+ except Exception:
226
+ existing_summary = {}
227
+ updates: dict[str, Any] = (
228
+ session.data_access_tracker.to_dict() if session.data_access_tracker is not None else {}
229
+ )
230
+ merged = {**existing_summary, **updates}
231
+ db_session_model.data_access_summary = merged # type: ignore
232
+ db_session.commit()
233
+
234
+
197
235
  class SessionTrackingMiddleware(Middleware):
198
236
  """
199
237
  Middleware that tracks tool call statistics for all mounted tools.
@@ -235,6 +273,14 @@ class SessionTrackingMiddleware(Middleware):
235
273
 
236
274
  try:
237
275
  return await call_next(context) # type: ignore
276
+ except SecurityError as e:
277
+ # Avoid noisy tracebacks for expected security blocks
278
+ log.warning(f"MCP request blocked by security policy: {e}")
279
+ raise
280
+ except ToolError as e:
281
+ # Upstream tool failed; avoid noisy traceback here. Specific handlers may format a response.
282
+ log.warning(f"MCP tool error: {e}")
283
+ raise
238
284
  except Exception:
239
285
  log.exception("MCP request handling failed")
240
286
  raise
@@ -252,7 +298,8 @@ class SessionTrackingMiddleware(Middleware):
252
298
  except Exception:
253
299
  log.exception("MCP list_tools failed")
254
300
  raise
255
- log.trace(f"🔍 on_list_tools response: {response}")
301
+ log.debug("🔍 listed raw tools.")
302
+ log.trace(f"🔍 on_list_tools response: length {len(response)}")
256
303
 
257
304
  session_id = current_session_id_ctxvar.get()
258
305
  if session_id is None:
@@ -330,7 +377,16 @@ class SessionTrackingMiddleware(Middleware):
330
377
  session_id, "tool", context.message.name, timeout_s=30.0
331
378
  )
332
379
  if not approved:
333
- raise
380
+ # Return formatted SecurityError message (includes ASCII art)
381
+ return ToolResult(
382
+ content=[
383
+ {
384
+ "type": "text",
385
+ "text": str(e),
386
+ }
387
+ ],
388
+ structured_content=None,
389
+ )
334
390
  # Approved: apply effects and proceed
335
391
  session.data_access_tracker.apply_effects_after_manual_approval(
336
392
  "tool", context.message.name
@@ -338,34 +394,44 @@ class SessionTrackingMiddleware(Middleware):
338
394
  # Telemetry: record tool call
339
395
  record_tool_call(context.message.name)
340
396
 
341
- # Update database session
342
- with create_db_session() as db_session:
343
- db_session_model = db_session.execute(
344
- select(MCPSessionModel).where(MCPSessionModel.session_id == session_id)
345
- ).scalar_one()
346
-
347
- # Convert tool calls to dict format for JSON storage
348
- tool_calls_dict = [
349
- {
350
- "id": tc.id,
351
- "tool_name": tc.tool_name,
352
- "parameters": tc.parameters,
353
- "timestamp": tc.timestamp.isoformat(),
354
- "duration_ms": tc.duration_ms,
355
- "status": tc.status,
356
- "result": tc.result,
357
- }
358
- for tc in session.tool_calls
359
- ]
360
- # Update the tool_calls for this session
361
- db_session_model.tool_calls = tool_calls_dict # type: ignore
362
- db_session_model.data_access_summary = session.data_access_tracker.to_dict() # type: ignore
363
-
364
- db_session.commit()
397
+ # Persist the pending call immediately so it appears in UI
398
+ _persist_session_to_db(session)
365
399
 
366
400
  log.trace(f"Tool call {context.message.name} added to session {session_id}")
367
401
 
368
- return await call_next(context) # type: ignore
402
+ # Execute tool and update status/duration based on outcome
403
+ start_time = time.perf_counter()
404
+ try:
405
+ result = await call_next(context) # type: ignore
406
+ new_tool_call.status = "ok"
407
+ new_tool_call.duration_ms = (time.perf_counter() - start_time) * 1000.0
408
+
409
+ _persist_session_to_db(session)
410
+
411
+ return result
412
+ except ToolError as e:
413
+ new_tool_call.status = "error"
414
+ new_tool_call.duration_ms = (time.perf_counter() - start_time) * 1000.0
415
+
416
+ _persist_session_to_db(session)
417
+
418
+ # Convert tool errors to a concise ToolResult rather than bubbling a stack trace
419
+ log.warning(f"Tool failed: {context.message.name}: {e}")
420
+ return ToolResult(
421
+ content=[
422
+ {
423
+ "type": "text",
424
+ "text": (f"Tool '{context.message.name}' failed: {str(e)}"),
425
+ }
426
+ ],
427
+ structured_content=None,
428
+ )
429
+ except Exception:
430
+ new_tool_call.status = "error"
431
+ new_tool_call.duration_ms = (time.perf_counter() - start_time) * 1000.0
432
+
433
+ _persist_session_to_db(session)
434
+ raise
369
435
 
370
436
  # Hooks for Resources
371
437
  async def on_list_resources( # noqa
@@ -381,7 +447,7 @@ class SessionTrackingMiddleware(Middleware):
381
447
  except Exception:
382
448
  log.exception("MCP list_resources failed")
383
449
  raise
384
- log.trace(f"🔍 on_list_resources response: {response}")
450
+ log.trace(f"🔍 on_list_resources response: length {len(response)}")
385
451
 
386
452
  session_id = current_session_id_ctxvar.get()
387
453
  if session_id is None:
@@ -457,7 +523,10 @@ class SessionTrackingMiddleware(Middleware):
457
523
  session_id, "resource", resource_name, timeout_s=30.0
458
524
  )
459
525
  if not approved:
460
- raise
526
+ return {
527
+ "type": "text",
528
+ "text": str(e),
529
+ }
461
530
  session.data_access_tracker.apply_effects_after_manual_approval(
462
531
  "resource", resource_name
463
532
  )
@@ -469,7 +538,16 @@ class SessionTrackingMiddleware(Middleware):
469
538
  select(MCPSessionModel).where(MCPSessionModel.session_id == session_id)
470
539
  ).scalar_one()
471
540
 
472
- db_session_model.data_access_summary = session.data_access_tracker.to_dict() # type: ignore
541
+ # Use helper to preserve created_at and merge updates
542
+ existing_summary: dict[str, Any] = {}
543
+ try:
544
+ if isinstance(db_session_model.data_access_summary, dict): # type: ignore
545
+ existing_summary = dict(db_session_model.data_access_summary) # type: ignore
546
+ except Exception:
547
+ existing_summary = {}
548
+ updates: dict[str, Any] = session.data_access_tracker.to_dict()
549
+ merged: dict[str, Any] = {**existing_summary, **updates}
550
+ db_session_model.data_access_summary = merged # type: ignore
473
551
  db_session.commit()
474
552
 
475
553
  log.trace(f"Resource access {resource_name} added to session {session_id}")
@@ -493,7 +571,7 @@ class SessionTrackingMiddleware(Middleware):
493
571
  except Exception:
494
572
  log.exception("MCP list_prompts failed")
495
573
  raise
496
- log.debug(f"🔍 on_list_prompts response: {response}")
574
+ log.debug(f"🔍 on_list_prompts response: length {len(response)}")
497
575
 
498
576
  session_id = current_session_id_ctxvar.get()
499
577
  if session_id is None:
@@ -568,7 +646,10 @@ class SessionTrackingMiddleware(Middleware):
568
646
  session_id, "prompt", prompt_name, timeout_s=30.0
569
647
  )
570
648
  if not approved:
571
- raise
649
+ return {
650
+ "type": "text",
651
+ "text": str(e),
652
+ }
572
653
  session.data_access_tracker.apply_effects_after_manual_approval("prompt", prompt_name)
573
654
  record_prompt_used(prompt_name)
574
655
 
@@ -578,7 +659,15 @@ class SessionTrackingMiddleware(Middleware):
578
659
  select(MCPSessionModel).where(MCPSessionModel.session_id == session_id)
579
660
  ).scalar_one()
580
661
 
581
- db_session_model.data_access_summary = session.data_access_tracker.to_dict() # type: ignore
662
+ existing_summary = {}
663
+ try:
664
+ if isinstance(db_session_model.data_access_summary, dict): # type: ignore
665
+ existing_summary = dict(db_session_model.data_access_summary) # type: ignore
666
+ except Exception:
667
+ existing_summary = {}
668
+ updates: dict[str, Any] = session.data_access_tracker.to_dict()
669
+ merged: dict[str, Any] = {**existing_summary, **updates}
670
+ db_session_model.data_access_summary = merged # type: ignore
582
671
  db_session.commit()
583
672
 
584
673
  log.trace(f"Prompt access {prompt_name} added to session {session_id}")
src/oauth_manager.py CHANGED
@@ -84,7 +84,7 @@ class OAuthManager:
84
84
  Returns:
85
85
  OAuthServerInfo with detection results
86
86
  """
87
- log.info(f"🔍 Checking OAuth requirement for {server_name}")
87
+ log.debug(f"🔍 Checking OAuth requirement for {server_name}")
88
88
 
89
89
  # If no mcp_url provided, this is a local server - no OAuth needed
90
90
  if not mcp_url:
@@ -95,7 +95,7 @@ class OAuthManager:
95
95
  self._oauth_info[server_name] = info
96
96
  return info
97
97
 
98
- log.info(f"🔍 Checking OAuth requirement for remote server {server_name} at {mcp_url}")
98
+ log.debug(f"🔍 Checking OAuth requirement for remote server {server_name} at {mcp_url}")
99
99
 
100
100
  try:
101
101
  # Check if auth is required (with timeout)