open-edison 0.1.64__py3-none-any.whl → 0.1.75rc1__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.64.dist-info → open_edison-0.1.75rc1.dist-info}/METADATA +1 -1
- open_edison-0.1.75rc1.dist-info/RECORD +41 -0
- src/cli.py +5 -4
- src/config.py +31 -27
- src/events.py +5 -2
- src/frontend_dist/assets/index-D05VN_1l.css +1 -0
- src/frontend_dist/assets/index-D6ziuTsl.js +51 -0
- src/frontend_dist/index.html +2 -2
- src/frontend_dist/sw.js +22 -2
- src/mcp_importer/exporters.py +1 -1
- src/mcp_stdio_capture.py +144 -0
- src/middleware/data_access_tracker.py +49 -4
- src/middleware/session_tracking.py +123 -34
- src/oauth_manager.py +2 -2
- src/permissions.py +86 -9
- src/server.py +27 -6
- src/setup_tui/main.py +6 -4
- src/single_user_mcp.py +246 -109
- open_edison-0.1.64.dist-info/RECORD +0 -40
- src/frontend_dist/assets/index-BUUcUfTt.js +0 -51
- src/frontend_dist/assets/index-o6_8mdM8.css +0 -1
- {open_edison-0.1.64.dist-info → open_edison-0.1.75rc1.dist-info}/WHEEL +0 -0
- {open_edison-0.1.64.dist-info → open_edison-0.1.75rc1.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.64.dist-info → open_edison-0.1.75rc1.dist-info}/licenses/LICENSE +0 -0
src/frontend_dist/index.html
CHANGED
@@ -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-
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
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:
|
65
|
-
event.waitUntil(
|
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
|
}
|
src/mcp_importer/exporters.py
CHANGED
@@ -300,7 +300,7 @@ def export_to_vscode(
|
|
300
300
|
|
301
301
|
# Build the minimal config
|
302
302
|
new_config: dict[str, Any] = {
|
303
|
-
"
|
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
|
src/mcp_stdio_capture.py
ADDED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
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
|
-
#
|
342
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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.
|
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.
|
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)
|