open-edison 0.1.43__py3-none-any.whl → 0.1.64__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/mcp_importer/api.py CHANGED
@@ -1,12 +1,15 @@
1
1
  # pyright: reportMissingImports=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false
2
2
  import asyncio
3
- from collections.abc import Awaitable
3
+ import contextlib
4
4
  from enum import Enum
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
+ import questionary
9
+ from fastmcp import Client as FastMCPClient
8
10
  from fastmcp import FastMCP
9
- from loguru import logger as log
11
+ from fastmcp.client.auth.oauth import FileTokenStorage
12
+ from loguru import logger as log # kept for non-TUI contexts; printing used in TUI flows
10
13
 
11
14
  from src.config import Config, MCPServerConfig, get_config_json_path
12
15
  from src.mcp_importer import paths as _paths
@@ -23,6 +26,8 @@ from src.mcp_importer.importers import (
23
26
  )
24
27
  from src.mcp_importer.merge import MergePolicy, merge_servers
25
28
  from src.oauth_manager import OAuthStatus, get_oauth_manager
29
+ from src.oauth_override import OpenEdisonOAuth
30
+ from src.tools.io import suppress_fds
26
31
 
27
32
 
28
33
  class CLIENT(str, Enum):
@@ -133,12 +138,140 @@ def export_edison_to(
133
138
  def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
134
139
  """Minimal validation: try listing tools/resources/prompts via FastMCP within a timeout."""
135
140
 
136
- async def _verify_async() -> bool:
141
+ async def _verify_async() -> bool: # noqa: C901
137
142
  if not server.command.strip():
138
143
  return False
144
+ oauth_info = None
139
145
 
140
- # Inline backend config and capability listing (no extra helpers)
141
- backend_cfg: dict[str, Any] = {
146
+ # If this is a remote server, consult OAuth requirement first. Only skip
147
+ # verification when OAuth is actually required and no tokens are present.
148
+ if server.is_remote_server():
149
+ remote_url: str | None = server.get_remote_url()
150
+ if remote_url:
151
+ oauth_info = await get_oauth_manager().check_oauth_requirement(
152
+ server.name, remote_url
153
+ )
154
+ if oauth_info.status != OAuthStatus.NOT_REQUIRED:
155
+ # Token presence check
156
+ storage = FileTokenStorage(
157
+ server_url=remote_url, cache_dir=get_oauth_manager().cache_dir
158
+ )
159
+ tokens = await storage.get_tokens()
160
+ no_tokens: bool = not tokens or (
161
+ not getattr(tokens, "access_token", None)
162
+ and not getattr(tokens, "refresh_token", None)
163
+ )
164
+ # Detect if inline headers are present in args (translated from config)
165
+ has_inline_headers: bool = any(
166
+ (a == "--header" or a.startswith("--header")) for a in server.args
167
+ )
168
+ if (
169
+ oauth_info.status == OAuthStatus.NEEDS_AUTH
170
+ and no_tokens
171
+ and not has_inline_headers
172
+ ):
173
+ questionary.print(
174
+ f"Skipping verification for remote server '{server.name}' pending OAuth",
175
+ style="bold fg:ansiyellow",
176
+ )
177
+ return True
178
+
179
+ # Remote servers
180
+ if server.is_remote_server():
181
+ connection_timeout = 10.0
182
+ remote_url = server.get_remote_url()
183
+ if remote_url:
184
+ # If inline headers are specified (e.g., API key), verify via proxy to honor headers
185
+ has_inline_headers_remote: bool = any(
186
+ (a == "--header" or a.startswith("--header")) for a in server.args
187
+ )
188
+ if has_inline_headers_remote:
189
+ backend_cfg_remote: dict[str, Any] = {
190
+ "mcpServers": {
191
+ server.name: {
192
+ "command": server.command,
193
+ "args": server.args,
194
+ "env": server.env or {},
195
+ **({"roots": server.roots} if server.roots else {}),
196
+ }
197
+ }
198
+ }
199
+ proxy_remote: FastMCP[Any] | None = None
200
+ host_remote: FastMCP[Any] | None = None
201
+ try:
202
+ # TODO: In debug mode, do not suppress child process output.
203
+ with suppress_fds(suppress_stdout=True, suppress_stderr=True):
204
+ proxy_remote = FastMCP.as_proxy(backend_cfg_remote)
205
+ host_remote = FastMCP(name=f"open-edison-verify-host-{server.name}")
206
+ host_remote.mount(proxy_remote, prefix=server.name)
207
+
208
+ async def _list_tools_only() -> Any:
209
+ return await host_remote._tool_manager.list_tools() # type: ignore[attr-defined]
210
+
211
+ await asyncio.wait_for(_list_tools_only(), timeout=10.0)
212
+ return True
213
+ except Exception as e:
214
+ log.error(
215
+ "MCP remote (headers) verification failed for '{}': {}", server.name, e
216
+ )
217
+ return False
218
+ finally:
219
+ for obj in (host_remote, proxy_remote):
220
+ if isinstance(obj, FastMCP):
221
+ with contextlib.suppress(Exception):
222
+ result = obj.shutdown() # type: ignore[attr-defined]
223
+ await asyncio.wait_for(result, timeout=2.0) # type: ignore[func-returns-value]
224
+ # Otherwise, avoid triggering OAuth flows during verification
225
+ ping_succeeded = False
226
+ try:
227
+ if oauth_info is None:
228
+ oauth_info = await get_oauth_manager().check_oauth_requirement(
229
+ server.name, remote_url
230
+ )
231
+ # If OAuth is needed or we are already authenticated, don't initiate browser flows here
232
+ if oauth_info.status in (OAuthStatus.NEEDS_AUTH, OAuthStatus.AUTHENTICATED):
233
+ return True
234
+ # NOT_REQUIRED: quick unauthenticated ping
235
+ # TODO: In debug mode, do not suppress child process output.
236
+ questionary.print(
237
+ f"Testing connection to '{server.name}'... (timeout: {connection_timeout}s)",
238
+ style="bold fg:ansigreen",
239
+ )
240
+ log.debug(f"Establishing contact with remote server '{server.name}'")
241
+ async with asyncio.timeout(connection_timeout):
242
+ async with FastMCPClient(
243
+ remote_url,
244
+ auth=None,
245
+ timeout=connection_timeout,
246
+ init_timeout=connection_timeout,
247
+ ) as client:
248
+ log.debug(f"Connection established to '{server.name}'; pinging...")
249
+ with suppress_fds(suppress_stdout=True, suppress_stderr=True):
250
+ await asyncio.wait_for(fut=client.ping(), timeout=1.0)
251
+ log.info(f"Ping received from '{server.name}'; shutting down client")
252
+ ping_succeeded = True
253
+ log.debug(f"Client '{server.name}' shut down")
254
+ return ping_succeeded
255
+ except TimeoutError:
256
+ if ping_succeeded:
257
+ questionary.print(
258
+ f"Ping received from '{server.name}' but shutdown timed out (treating as success)",
259
+ style="bold fg:ansiyellow",
260
+ )
261
+ else:
262
+ questionary.print(
263
+ f"Verification timed out (> {connection_timeout}s) for '{server.name}'",
264
+ style="bold fg:ansired",
265
+ )
266
+ return ping_succeeded
267
+ except Exception as e: # noqa: BLE001
268
+ questionary.print(
269
+ f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
270
+ )
271
+ return False
272
+
273
+ # Local/stdio servers: mount via proxy and perform a single light operation (tools only)
274
+ backend_cfg_local: dict[str, Any] = {
142
275
  "mcpServers": {
143
276
  server.name: {
144
277
  "command": server.command,
@@ -149,56 +282,133 @@ def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
149
282
  }
150
283
  }
151
284
 
152
- proxy: FastMCP[Any] | None = None
153
- host: FastMCP[Any] | None = None
285
+ proxy_local: FastMCP[Any] | None = None
286
+ host_local: FastMCP[Any] | None = None
154
287
  try:
155
- proxy = FastMCP.as_proxy(backend_cfg)
156
- host = FastMCP(name=f"open-edison-verify-host-{server.name}")
157
- host.mount(proxy, prefix=server.name)
158
-
159
- async def _call_list(kind: str) -> Any:
160
- manager_name = {
161
- "tools": "_tool_manager",
162
- "resources": "_resource_manager",
163
- "prompts": "_prompt_manager",
164
- }[kind]
165
- manager = getattr(host, manager_name)
166
- return await getattr(manager, f"list_{kind}")()
167
-
168
- await asyncio.wait_for(
169
- asyncio.gather(
170
- _call_list("tools"),
171
- _call_list("resources"),
172
- _call_list("prompts"),
173
- ),
174
- timeout=30.0,
175
- )
288
+ # TODO: In debug mode, do not suppress child process output.
289
+ log.info("Checking properties of '{}'...", server.name)
290
+ with suppress_fds(suppress_stdout=True, suppress_stderr=True):
291
+ proxy_local = FastMCP.as_proxy(backend_cfg_local)
292
+ host_local = FastMCP(name=f"open-edison-verify-host-{server.name}")
293
+ host_local.mount(proxy_local, prefix=server.name)
294
+ log.info("MCP properties check succeeded for '{}'", server.name)
295
+
296
+ async def _list_tools_only() -> Any:
297
+ return await host_local._tool_manager.list_tools() # type: ignore[attr-defined]
298
+
299
+ await asyncio.wait_for(_list_tools_only(), timeout=30.0)
176
300
  return True
177
301
  except Exception as e:
178
- log.error("MCP verification failed for '{}': {}", server.name, e)
302
+ questionary.print(
303
+ f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
304
+ )
179
305
  return False
180
306
  finally:
181
- try:
182
- for obj in (host, proxy):
183
- if isinstance(obj, FastMCP):
307
+ for obj in (host_local, proxy_local):
308
+ if isinstance(obj, FastMCP):
309
+ with contextlib.suppress(Exception):
184
310
  result = obj.shutdown() # type: ignore[attr-defined]
185
- if isinstance(result, Awaitable):
186
- await result # type: ignore[func-returns-value]
187
- except Exception:
188
- pass
311
+ await asyncio.wait_for(result, timeout=2.0) # type: ignore[func-returns-value]
189
312
 
190
313
  return asyncio.run(_verify_async())
191
314
 
192
315
 
193
- def server_needs_oauth(server: MCPServerConfig) -> bool: # noqa
194
- """Return True if the remote server currently needs OAuth; False otherwise."""
316
+ def authorize_server_oauth(server: MCPServerConfig) -> bool:
317
+ """Run an interactive OAuth flow for a remote MCP server and cache tokens.
318
+
319
+ Returns True if authorization succeeded (tokens cached and a ping succeeded),
320
+ False otherwise. Local servers return True immediately.
321
+ """
195
322
 
196
- async def _needs_oauth_async() -> bool:
323
+ async def _authorize_async() -> bool:
197
324
  if not server.is_remote_server():
325
+ return True
326
+
327
+ remote_url: str | None = server.get_remote_url()
328
+ if not remote_url:
329
+ log.error("OAuth requested for remote server '{}' but no URL found", server.name)
330
+ return False
331
+
332
+ oauth_manager = get_oauth_manager()
333
+
334
+ try:
335
+ # Debug info prior to starting OAuth
336
+ print(
337
+ "[OAuth] Starting authorization",
338
+ f"server={server.name}",
339
+ f"remote_url={remote_url}",
340
+ f"cache_dir={oauth_manager.cache_dir}",
341
+ f"scopes={server.oauth_scopes}",
342
+ f"client_name={server.oauth_client_name or 'Open Edison Setup'}",
343
+ )
344
+
345
+ oauth = OpenEdisonOAuth(
346
+ mcp_url=remote_url,
347
+ scopes=server.oauth_scopes,
348
+ client_name=server.oauth_client_name or "Open Edison Setup",
349
+ token_storage_cache_dir=oauth_manager.cache_dir,
350
+ callback_port=50001,
351
+ )
352
+
353
+ # Establish a connection to trigger OAuth if needed
354
+ async with FastMCPClient(remote_url, auth=oauth) as client: # type: ignore
355
+ log.info(
356
+ "Starting OAuth flow for '{}' (a browser window may open; if not, follow the printed URL)",
357
+ server.name,
358
+ )
359
+ await client.ping()
360
+
361
+ # Refresh cached status
362
+ info = await oauth_manager.check_oauth_requirement(server.name, remote_url)
363
+
364
+ # Post-authorization token inspection (no secrets printed)
365
+ try:
366
+ storage = FileTokenStorage(server_url=remote_url, cache_dir=oauth_manager.cache_dir)
367
+ tokens = await storage.get_tokens()
368
+ access_present = bool(getattr(tokens, "access_token", None)) if tokens else False
369
+ refresh_present = bool(getattr(tokens, "refresh_token", None)) if tokens else False
370
+ expires_at = getattr(tokens, "expires_at", None) if tokens else None
371
+ print(
372
+ "[OAuth] Authorization result:",
373
+ f"status={info.status.value}",
374
+ f"has_refresh_token={info.has_refresh_token}",
375
+ f"token_expires_at={info.token_expires_at or expires_at}",
376
+ f"tokens_cached=access:{access_present}/refresh:{refresh_present}",
377
+ )
378
+ except Exception as _e: # noqa: BLE001
379
+ print("[OAuth] Authorization completed, but token inspection failed:", _e)
380
+
381
+ log.info("OAuth completed and tokens cached for '{}'", server.name)
382
+ return True
383
+ except Exception as e: # noqa: BLE001
384
+ log.error("OAuth authorization failed for '{}': {}", server.name, e)
385
+ print("[OAuth] Authorization failed:", e)
386
+ return False
387
+
388
+ return asyncio.run(_authorize_async())
389
+
390
+
391
+ def has_oauth_tokens(server: MCPServerConfig) -> bool:
392
+ """Return True if cached OAuth tokens exist for the remote server.
393
+
394
+ Local servers return True (no OAuth needed).
395
+ """
396
+
397
+ async def _check_async() -> bool:
398
+ if not server.is_remote_server():
399
+ return True
400
+
401
+ remote_url: str | None = server.get_remote_url()
402
+ if not remote_url:
403
+ return False
404
+
405
+ try:
406
+ storage = FileTokenStorage(
407
+ server_url=remote_url, cache_dir=get_oauth_manager().cache_dir
408
+ )
409
+ tokens = await storage.get_tokens()
410
+ return bool(tokens and (tokens.access_token or tokens.refresh_token))
411
+ except Exception:
198
412
  return False
199
- info = await get_oauth_manager().check_oauth_requirement(
200
- server.name, server.get_remote_url()
201
- )
202
- return info.status == OAuthStatus.NEEDS_AUTH
203
413
 
204
- return asyncio.run(_needs_oauth_async())
414
+ return asyncio.run(_check_async())
@@ -5,8 +5,10 @@ from loguru import logger as log
5
5
 
6
6
  from .exporters import ExportError, export_to_claude_code, export_to_cursor, export_to_vscode
7
7
  from .paths import (
8
+ detect_claude_code_config_path,
8
9
  detect_cursor_config_path,
9
10
  detect_vscode_config_path,
11
+ get_default_claude_code_config_path,
10
12
  get_default_cursor_config_path,
11
13
  get_default_vscode_config_path,
12
14
  )
@@ -135,8 +137,6 @@ def _handle_vscode(args: argparse.Namespace) -> int:
135
137
 
136
138
 
137
139
  def _handle_claude_code(args: argparse.Namespace) -> int:
138
- from .paths import detect_claude_code_config_path, get_default_claude_code_config_path
139
-
140
140
  detected = detect_claude_code_config_path()
141
141
  target_path: Path = detected if detected else get_default_claude_code_config_path()
142
142
 
@@ -1,3 +1 @@
1
- from __future__ import annotations
2
-
3
1
  # legacy helpers were removed to satisfy deadcode scanning
@@ -1,6 +1,7 @@
1
1
  # pyright: reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownMemberType=false
2
2
 
3
3
  import json
4
+ import shlex
4
5
  from pathlib import Path
5
6
  from typing import Any, cast
6
7
 
@@ -50,7 +51,7 @@ def safe_read_json(path: Path) -> dict[str, Any]:
50
51
  return data
51
52
 
52
53
 
53
- def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool) -> Any:
54
+ def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool) -> Any: # noqa: C901
54
55
  command_val = node.get("command", "")
55
56
  command = str(command_val) if isinstance(command_val, str) else ""
56
57
 
@@ -67,12 +68,35 @@ def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool)
67
68
 
68
69
  args: list[str] = [str(a) for a in args_raw]
69
70
 
71
+ # If command is provided as a full string with flags, split into program + args
72
+ if command and (" " in command or command.endswith(("\t", "\n"))):
73
+ try:
74
+ parts = shlex.split(command)
75
+ if parts:
76
+ command = parts[0]
77
+ # Prepend split args before any provided args to preserve order
78
+ args = parts[1:] + args
79
+ except Exception:
80
+ # If shlex fails, keep original command/args
81
+ pass
82
+
70
83
  env_raw = node.get("env") or node.get("environment") or {}
71
84
  env: dict[str, str] = {}
72
85
  if isinstance(env_raw, dict):
73
86
  for k, v in env_raw.items():
74
87
  env[str(k)] = str(v)
75
88
 
89
+ # Support Cursor-style remote config: { "url": "...", "headers": {...} }
90
+ # Translate to `npx mcp-remote <url> [--header Key: Value]*` so downstream verification works.
91
+ url_val = node.get("url")
92
+ if isinstance(url_val, str) and url_val:
93
+ command = "npx"
94
+ args = ["-y", "mcp-remote", url_val]
95
+ headers_raw = node.get("headers")
96
+ if isinstance(headers_raw, dict):
97
+ for hk, hv in headers_raw.items():
98
+ args.extend(["--header", f"{str(hk)}: {str(hv)}"])
99
+
76
100
  enabled = bool(node.get("enabled", default_enabled))
77
101
 
78
102
  roots_raw = node.get("roots") or node.get("rootPaths") or []
@@ -135,14 +159,28 @@ def _collect_nested(data: dict[str, Any], default_enabled: bool) -> list[Any]:
135
159
  return results
136
160
 
137
161
 
138
- def parse_mcp_like_json(data: dict[str, Any], default_enabled: bool = True) -> list[Any]:
162
+ def deduplicate_by_name(servers: list[MCPServerConfig]) -> list[MCPServerConfig]:
163
+ result: list[MCPServerConfig] = []
164
+ names = set()
165
+ for server in servers:
166
+ if server.name not in names:
167
+ names.add(server.name)
168
+ result.append(server)
169
+ return result
170
+
171
+
172
+ def parse_mcp_like_json(
173
+ data: dict[str, Any], default_enabled: bool = True
174
+ ) -> list[MCPServerConfig]:
139
175
  # First, try top-level keys
140
176
  top_level = _collect_top_level(data, default_enabled)
177
+ res: list[MCPServerConfig] = []
141
178
  if top_level:
142
- return top_level
143
-
144
- # Then, try nested structures heuristically
145
- nested = _collect_nested(data, default_enabled)
146
- if not nested:
147
- log.debug("No MCP-like entries detected in provided data")
148
- return nested
179
+ res = top_level
180
+ else:
181
+ # Then, try nested structures heuristically
182
+ nested = _collect_nested(data, default_enabled)
183
+ if not nested:
184
+ log.debug("No MCP-like entries detected in provided data")
185
+ res = nested
186
+ return deduplicate_by_name(res)
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import argparse
4
2
  from collections.abc import Iterable
5
3
 
src/mcp_importer/types.py CHANGED
@@ -1,5 +1,3 @@
1
1
  """Type helpers for MCP importer."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  # This module intentionally minimal to avoid unused code flags.
src/oauth_manager.py CHANGED
@@ -19,6 +19,8 @@ from fastmcp.client.auth.oauth import (
19
19
  )
20
20
  from loguru import logger as log
21
21
 
22
+ from src.oauth_override import OpenEdisonOAuth
23
+
22
24
 
23
25
  class OAuthStatus(Enum):
24
26
  """OAuth authentication status for MCP servers."""
@@ -209,7 +211,7 @@ class OAuthManager:
209
211
  return None
210
212
 
211
213
  try:
212
- oauth = OAuth(
214
+ oauth = OpenEdisonOAuth(
213
215
  mcp_url=mcp_url,
214
216
  scopes=scopes or info.scopes,
215
217
  client_name=client_name or info.client_name,
src/oauth_override.py ADDED
@@ -0,0 +1,10 @@
1
+ import webbrowser
2
+
3
+ from fastmcp.client.auth.oauth import OAuth as _FastMCPOAuth
4
+
5
+
6
+ class OpenEdisonOAuth(_FastMCPOAuth):
7
+ async def redirect_handler(self, authorization_url: str) -> None: # noqa: ARG002
8
+ # Print a clean, single-line URL and still open the browser.
9
+ print(f"OAuth authorization URL: {authorization_url}", flush=True)
10
+ webbrowser.open(authorization_url)
src/permissions.py CHANGED
@@ -7,6 +7,7 @@ Reads tool, resource, and prompt permission files and provides a singleton inter
7
7
 
8
8
  import json
9
9
  from dataclasses import dataclass
10
+ from functools import cache
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
@@ -158,6 +159,12 @@ class Permissions:
158
159
  )
159
160
 
160
161
  @classmethod
162
+ def clear_permissions_file_cache(cls) -> None:
163
+ """Clear the cache for the JSON permissions files"""
164
+ cls._load_permission_file.cache_clear()
165
+
166
+ @classmethod
167
+ @cache
161
168
  def _load_permission_file(
162
169
  cls,
163
170
  file_path: Path,
@@ -171,7 +178,23 @@ class Permissions:
171
178
  metadata: PermissionsMetadata | None = None
172
179
 
173
180
  if not file_path.exists():
174
- raise PermissionsError(f"Permissions file not found at {file_path}")
181
+ # Bootstrap missing permissions files on first run.
182
+ # Prefer copying repo/package defaults (next to src/), else create minimal stub.
183
+ file_path.parent.mkdir(parents=True, exist_ok=True)
184
+
185
+ repo_candidate = Path(__file__).parent.parent / file_path.name
186
+ if repo_candidate.exists():
187
+ file_path.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
188
+ log.info(f"Bootstrapped permissions file from defaults: {file_path}")
189
+ if not file_path.exists():
190
+ # Create minimal empty structure
191
+ try:
192
+ file_path.write_text(json.dumps({"_metadata": {}}), encoding="utf-8")
193
+ log.info(f"Created empty permissions file: {file_path}")
194
+ except Exception as e:
195
+ raise PermissionsError(
196
+ f"Unable to create permissions file at {file_path}: {e}", file_path
197
+ ) from e
175
198
 
176
199
  with open(file_path) as f:
177
200
  data: dict[str, Any] = json.load(f)
src/server.py CHANGED
@@ -1,12 +1,12 @@
1
1
  """
2
- Open Edison Server
3
-
4
- Simple FastAPI + FastMCP server for single-user MCP proxy.
5
- No multi-user support, no complex routing - just a straightforward proxy.
2
+ Open Edison MCP Proxy Server
3
+ Main server entrypoint for FastAPI and FastMCP integration.
4
+ See README for usage and configuration details.
6
5
  """
7
6
 
8
7
  import asyncio
9
8
  import json
9
+ import signal
10
10
  import traceback
11
11
  from collections.abc import Awaitable, Callable, Coroutine
12
12
  from contextlib import suppress
@@ -25,6 +25,7 @@ from fastapi.responses import (
25
25
  )
26
26
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
27
27
  from fastapi.staticfiles import StaticFiles
28
+ from fastmcp import Client as FastMCPClient
28
29
  from fastmcp import FastMCP
29
30
  from loguru import logger as log
30
31
  from pydantic import BaseModel, Field
@@ -37,6 +38,8 @@ from src.middleware.session_tracking import (
37
38
  create_db_session,
38
39
  )
39
40
  from src.oauth_manager import OAuthStatus, get_oauth_manager
41
+ from src.oauth_override import OpenEdisonOAuth
42
+ from src.permissions import Permissions
40
43
  from src.single_user_mcp import SingleUserMCP
41
44
  from src.telemetry import initialize_telemetry, set_servers_installed
42
45
 
@@ -276,6 +279,12 @@ class OpenEdisonProxy:
276
279
  # Clear cache for the config file, if it was config.json
277
280
  if name == "config.json":
278
281
  clear_json_file_cache()
282
+ elif name in (
283
+ "tool_permissions.json",
284
+ "resource_permissions.json",
285
+ "prompt_permissions.json",
286
+ ):
287
+ Permissions.clear_permissions_file_cache()
279
288
 
280
289
  return {"status": "ok"}
281
290
  except Exception as e: # noqa: BLE001
@@ -397,6 +406,7 @@ class OpenEdisonProxy:
397
406
  host=self.host,
398
407
  port=self.port + 1,
399
408
  log_level=Config().logging.level.lower(),
409
+ timeout_graceful_shutdown=0,
400
410
  )
401
411
  fastapi_server = uvicorn.Server(fastapi_config)
402
412
  servers_to_run.append(fastapi_server.serve())
@@ -408,13 +418,27 @@ class OpenEdisonProxy:
408
418
  host=self.host,
409
419
  port=self.port,
410
420
  log_level=Config().logging.level.lower(),
421
+ timeout_graceful_shutdown=0,
411
422
  )
412
423
  fastmcp_server = uvicorn.Server(fastmcp_config)
413
424
  servers_to_run.append(fastmcp_server.serve())
414
425
 
415
426
  # Run both servers concurrently
416
427
  log.info("🚀 Starting both FastAPI and FastMCP servers...")
417
- _ = await asyncio.gather(*servers_to_run)
428
+ loop = asyncio.get_running_loop()
429
+
430
+ def _trigger_shutdown(signame: str) -> None:
431
+ log.info(f"Received {signame}. Forcing shutdown of all servers...")
432
+ for srv in (fastapi_server, fastmcp_server):
433
+ with suppress(Exception):
434
+ srv.force_exit = True # type: ignore[attr-defined] # noqa
435
+ srv.should_exit = True # type: ignore[attr-defined] # noqa
436
+
437
+ for sig in (signal.SIGINT, signal.SIGTERM):
438
+ with suppress(Exception):
439
+ loop.add_signal_handler(sig, _trigger_shutdown, sig.name)
440
+
441
+ await asyncio.gather(*servers_to_run, return_exceptions=False)
418
442
 
419
443
  def _register_routes(self, app: FastAPI) -> None:
420
444
  """Register all routes for the FastAPI app"""
@@ -953,12 +977,8 @@ class OpenEdisonProxy:
953
977
 
954
978
  log.info(f"🔗 Testing connection to {server_name} at {remote_url}")
955
979
 
956
- # Import FastMCP client for testing
957
- from fastmcp import Client as FastMCPClient
958
- from fastmcp.client.auth import OAuth
959
-
960
980
  # Create OAuth auth object
961
- oauth = OAuth(
981
+ oauth = OpenEdisonOAuth(
962
982
  mcp_url=remote_url,
963
983
  scopes=scopes,
964
984
  client_name=client_name or "OpenEdison MCP Gateway",
@@ -0,0 +1,5 @@
1
+ """Setup TUI package initializer.
2
+
3
+ Intentionally empty to avoid importing submodules at package import time,
4
+ which prevents warnings when executing `python -m src.setup_tui.main`.
5
+ """