open-edison 0.1.44__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.
- {open_edison-0.1.44.dist-info → open_edison-0.1.72rc1.dist-info}/METADATA +2 -21
- open_edison-0.1.72rc1.dist-info/RECORD +41 -0
- src/cli.py +30 -113
- src/config.py +30 -9
- 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/__main__.py +0 -2
- src/mcp_importer/api.py +254 -44
- src/mcp_importer/export_cli.py +2 -2
- src/mcp_importer/exporters.py +1 -1
- src/mcp_importer/import_api.py +0 -2
- src/mcp_importer/parsers.py +47 -9
- src/mcp_importer/quick_cli.py +0 -2
- src/mcp_importer/types.py +0 -2
- 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 +5 -3
- src/oauth_override.py +10 -0
- src/permissions.py +110 -10
- src/server.py +57 -16
- src/setup_tui/main.py +160 -21
- src/single_user_mcp.py +246 -105
- src/tools/io.py +35 -0
- src/vulture_whitelist.py +3 -0
- open_edison-0.1.44.dist-info/RECORD +0 -37
- src/frontend_dist/assets/index-BUUcUfTt.js +0 -51
- src/frontend_dist/assets/index-o6_8mdM8.css +0 -1
- {open_edison-0.1.44.dist-info → open_edison-0.1.72rc1.dist-info}/WHEEL +0 -0
- {open_edison-0.1.44.dist-info → open_edison-0.1.72rc1.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.44.dist-info → open_edison-0.1.72rc1.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/__main__.py
CHANGED
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
|
-
|
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
|
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
|
-
#
|
141
|
-
|
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
|
-
|
153
|
-
|
285
|
+
proxy_local: FastMCP[Any] | None = None
|
286
|
+
host_local: FastMCP[Any] | None = None
|
154
287
|
try:
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
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
|
-
|
302
|
+
questionary.print(
|
303
|
+
f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
|
304
|
+
)
|
179
305
|
return False
|
180
306
|
finally:
|
181
|
-
|
182
|
-
|
183
|
-
|
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
|
-
|
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
|
194
|
-
"""
|
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
|
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(
|
414
|
+
return asyncio.run(_check_async())
|
src/mcp_importer/export_cli.py
CHANGED
@@ -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
|
|
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_importer/import_api.py
CHANGED
src/mcp_importer/parsers.py
CHANGED
@@ -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
|
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
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
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)
|
src/mcp_importer/quick_cli.py
CHANGED