studio: show MCP "Import config" on the add-server form (#6030)
* studio: import MCP servers from a config file * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * import config' on the add-server form * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: defensively handle MCP config imports * fix: address MCP import review follow-ups * fix: preserve apostrophes in Windows MCP commands * fix: preserve apostrophe-wrapped Windows MCP args * fix: align Windows MCP parsing with list2cmdline * fix: preserve explicit MCP remote transport intent * fix: trim MCP remote URLs before transport checks --------- Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 <samleejackson0@gmail.com>
This commit is contained in:
parent
f64c3c8aba
commit
004577c9cd
7 changed files with 739 additions and 9 deletions
|
|
@ -25,16 +25,85 @@ def is_stdio(address: str) -> bool:
|
|||
return not address.strip().lower().startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _split_windows_command_line(address: str) -> list[str]:
|
||||
"""Parse a Windows command line using the same backslash/quote rules that
|
||||
subprocess.list2cmdline() writes. This keeps trailing backslashes before a
|
||||
closing quote from being doubled in the resulting argv."""
|
||||
parts: list[str] = []
|
||||
current: list[str] = []
|
||||
in_quotes = False
|
||||
backslashes = 0
|
||||
arg_started = False
|
||||
i = 0
|
||||
|
||||
while i < len(address):
|
||||
ch = address[i]
|
||||
if ch == "\\":
|
||||
backslashes += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
current.extend("\\" * (backslashes // 2))
|
||||
if backslashes % 2:
|
||||
current.append('"')
|
||||
else:
|
||||
in_quotes = not in_quotes
|
||||
arg_started = True
|
||||
backslashes = 0
|
||||
i += 1
|
||||
continue
|
||||
if ch.isspace() and not in_quotes:
|
||||
if backslashes:
|
||||
current.extend("\\" * backslashes)
|
||||
arg_started = True
|
||||
backslashes = 0
|
||||
if arg_started or current:
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
arg_started = False
|
||||
i += 1
|
||||
while i < len(address) and address[i].isspace():
|
||||
i += 1
|
||||
continue
|
||||
if backslashes:
|
||||
current.extend("\\" * backslashes)
|
||||
arg_started = True
|
||||
backslashes = 0
|
||||
current.append(ch)
|
||||
arg_started = True
|
||||
i += 1
|
||||
|
||||
if backslashes:
|
||||
current.extend("\\" * backslashes)
|
||||
arg_started = True
|
||||
if in_quotes:
|
||||
raise ValueError("No closing quotation")
|
||||
if arg_started or current:
|
||||
parts.append("".join(current))
|
||||
return parts
|
||||
|
||||
|
||||
def parse_stdio_command(address: str) -> list[str]:
|
||||
"""Split a stdio command line into argv. Shared by route validation and the
|
||||
transport so both agree on quoting (notably Windows backslash paths)."""
|
||||
posix = sys.platform != "win32"
|
||||
parts = shlex.split(address, posix = posix)
|
||||
if not posix:
|
||||
# posix=False keeps backslash paths but also keeps surrounding quotes;
|
||||
# strip a matched pair so argv reaches the subprocess clean.
|
||||
parts = [p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p for p in parts]
|
||||
return parts
|
||||
if posix:
|
||||
return shlex.split(address, posix = posix)
|
||||
if address.lstrip().startswith("'"):
|
||||
raise ValueError("Single-quoted executables are not supported on Windows")
|
||||
return _split_windows_command_line(address)
|
||||
|
||||
|
||||
def join_stdio_command(parts: list[str]) -> str:
|
||||
"""Inverse of parse_stdio_command: join argv into a single command string
|
||||
that parse_stdio_command() splits back into ``parts`` on this platform.
|
||||
Config files (issue #5936) carry structured command + args; storage holds
|
||||
one string in the url field. Windows uses list2cmdline so spaced/backslash
|
||||
paths round-trip through the posix=False quote-strip; posix uses shlex."""
|
||||
if sys.platform == "win32":
|
||||
import subprocess
|
||||
return subprocess.list2cmdline(parts)
|
||||
return shlex.join(parts)
|
||||
|
||||
|
||||
def stdio_mcp_enabled() -> bool:
|
||||
|
|
|
|||
169
studio/backend/core/inference/mcp_config_import.py
Normal file
169
studio/backend/core/inference/mcp_config_import.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Parse a standard ``mcpServers`` JSON config (Claude Desktop / Cursor / Cline
|
||||
/ VS Code) into entries the existing MCP storage understands. See issue #5936.
|
||||
|
||||
A stdio entry (``command`` + ``args`` + ``env``) is joined into the single
|
||||
command string the ``url`` field already stores; a remote entry (``url`` +
|
||||
``headers``) maps straight through. Parsing never raises on a single bad entry:
|
||||
it returns ``(entries, errors)`` so one malformed server can't sink the import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from core.inference.mcp_client import join_stdio_command
|
||||
|
||||
_SCALAR = (str, int, float, bool)
|
||||
_UNSUPPORTED_STDIO_FIELDS = ("cwd", "envFile")
|
||||
_UNSUPPORTED_TIMEOUT_FIELDS = ("timeout", "timeoutMs", "timeoutSeconds")
|
||||
_HTTP_REMOTE_TYPES = ("http", "streamableHttp")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedMcpEntry:
|
||||
display_name: str
|
||||
url: str # joined command (stdio) or http(s) url (remote)
|
||||
headers: Optional[dict[str, str]] # env vars (stdio) or http headers (remote)
|
||||
is_stdio: bool
|
||||
is_enabled: bool = True
|
||||
use_oauth: bool = False
|
||||
|
||||
|
||||
def _coerce_str_dict(value: dict) -> dict[str, str]:
|
||||
return {str(k): str(v) for k, v in value.items()}
|
||||
|
||||
|
||||
def _has_variable_reference(value: object) -> bool:
|
||||
if isinstance(value, str):
|
||||
return "${" in value
|
||||
if isinstance(value, list):
|
||||
return any(_has_variable_reference(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return any(_has_variable_reference(item) for item in value.values())
|
||||
return False
|
||||
|
||||
|
||||
def _has_null_value(value: object) -> bool:
|
||||
return isinstance(value, dict) and any(item is None for item in value.values())
|
||||
|
||||
|
||||
def _enabled_from_spec(label: str, spec: dict) -> tuple[Optional[bool], Optional[str]]:
|
||||
disabled = spec.get("disabled")
|
||||
if disabled is None:
|
||||
return True, None
|
||||
if not isinstance(disabled, bool):
|
||||
return None, f"{label}: 'disabled' must be true or false."
|
||||
return not disabled, None
|
||||
|
||||
|
||||
def _parse_entry(name: str, spec: object) -> tuple[Optional[ParsedMcpEntry], Optional[str]]:
|
||||
label = str(name).strip()
|
||||
if not label:
|
||||
return None, "Server entry has an empty name."
|
||||
if not isinstance(spec, dict):
|
||||
return None, f"{label}: entry must be an object."
|
||||
if _has_variable_reference(spec):
|
||||
return None, f"{label}: VS Code variable references are not supported by import."
|
||||
|
||||
is_enabled, error = _enabled_from_spec(label, spec)
|
||||
if error:
|
||||
return None, error
|
||||
|
||||
has_command = bool(spec.get("command"))
|
||||
has_url = bool(spec.get("url"))
|
||||
if has_command and has_url:
|
||||
return None, f"{label}: entry has both 'command' and 'url'; use one."
|
||||
if not has_command and not has_url:
|
||||
return None, f"{label}: entry needs a 'command' (stdio) or 'url' (remote)."
|
||||
|
||||
if has_command:
|
||||
command = spec["command"]
|
||||
if not isinstance(command, str):
|
||||
return None, f"{label}: 'command' must be a string."
|
||||
entry_type = spec.get("type")
|
||||
if entry_type is not None and entry_type != "stdio":
|
||||
return None, f"{label}: stdio entry has unsupported type {entry_type!r}."
|
||||
sandbox_enabled = spec.get("sandboxEnabled")
|
||||
if sandbox_enabled is not None and not isinstance(sandbox_enabled, bool):
|
||||
return None, f"{label}: 'sandboxEnabled' must be true or false."
|
||||
if sandbox_enabled:
|
||||
return None, f"{label}: sandboxed stdio servers cannot be preserved by import."
|
||||
unsupported = [field for field in _UNSUPPORTED_STDIO_FIELDS if spec.get(field) is not None]
|
||||
if unsupported:
|
||||
return None, f"{label}: import cannot preserve {', '.join(unsupported)}."
|
||||
if spec.get("oauth") is not None:
|
||||
return None, f"{label}: 'oauth' is only supported for remote servers."
|
||||
args = spec.get("args") or []
|
||||
if not isinstance(args, list) or not all(isinstance(a, _SCALAR) for a in args):
|
||||
return None, f"{label}: 'args' must be a list of strings."
|
||||
env = spec.get("env")
|
||||
if env is not None and not isinstance(env, dict):
|
||||
return None, f"{label}: 'env' must be an object."
|
||||
if _has_null_value(env):
|
||||
return None, f"{label}: null environment values are not supported by import."
|
||||
url = join_stdio_command([command, *(str(a) for a in args)])
|
||||
headers = _coerce_str_dict(env) if env else None
|
||||
return ParsedMcpEntry(label, url, headers, True, is_enabled = is_enabled), None
|
||||
|
||||
url = spec["url"]
|
||||
if not isinstance(url, str):
|
||||
return None, f"{label}: 'url' must be a string."
|
||||
url = url.strip()
|
||||
entry_type = spec.get("type")
|
||||
if entry_type is not None and entry_type not in (*_HTTP_REMOTE_TYPES, "sse"):
|
||||
return None, f"{label}: remote entry has unsupported type {entry_type!r}."
|
||||
unsupported_timeout = [
|
||||
field for field in _UNSUPPORTED_TIMEOUT_FIELDS if spec.get(field) is not None
|
||||
]
|
||||
if unsupported_timeout:
|
||||
return None, f"{label}: import cannot preserve {', '.join(unsupported_timeout)}."
|
||||
url_infers_sse = url.rstrip("/").endswith("/sse")
|
||||
if entry_type == "sse" and not url_infers_sse:
|
||||
return None, f"{label}: explicit SSE transport cannot be preserved for this URL."
|
||||
if entry_type in _HTTP_REMOTE_TYPES and url_infers_sse:
|
||||
return None, f"{label}: explicit HTTP transport cannot be preserved for this URL."
|
||||
oauth_raw = spec.get("oauth")
|
||||
if oauth_raw is not None and not isinstance(oauth_raw, dict):
|
||||
return None, f"{label}: 'oauth' must be an object."
|
||||
headers_raw = spec.get("headers")
|
||||
if headers_raw is not None and not isinstance(headers_raw, dict):
|
||||
return None, f"{label}: 'headers' must be an object."
|
||||
if _has_null_value(headers_raw):
|
||||
return None, f"{label}: null header values are not supported by import."
|
||||
headers = _coerce_str_dict(headers_raw) if headers_raw else None
|
||||
return ParsedMcpEntry(
|
||||
label,
|
||||
url,
|
||||
headers,
|
||||
False,
|
||||
is_enabled = is_enabled,
|
||||
use_oauth = oauth_raw is not None,
|
||||
), None
|
||||
|
||||
|
||||
def parse_mcp_config(config: object) -> tuple[list[ParsedMcpEntry], list[str]]:
|
||||
"""Parse a Claude-Desktop/Cursor/Cline/VS Code config. Accepts the
|
||||
``mcpServers`` key (primary) or ``servers`` (VS Code alias). Returns
|
||||
``(entries, errors)``; a bad entry adds an error rather than raising."""
|
||||
if not isinstance(config, dict):
|
||||
return [], ["Config must be a JSON object."]
|
||||
servers_key = "mcpServers" if "mcpServers" in config else "servers"
|
||||
servers = config.get(servers_key)
|
||||
if servers is None:
|
||||
return [], ["Config has no 'mcpServers' (or 'servers') object."]
|
||||
if not isinstance(servers, dict):
|
||||
return [], [f"'{servers_key}' must be an object mapping name -> server."]
|
||||
|
||||
entries: list[ParsedMcpEntry] = []
|
||||
errors: list[str] = []
|
||||
for name, spec in servers.items():
|
||||
entry, error = _parse_entry(name, spec)
|
||||
if error:
|
||||
errors.append(error)
|
||||
elif entry:
|
||||
entries.append(entry)
|
||||
return entries, errors
|
||||
|
|
@ -44,3 +44,14 @@ class McpServerProbeResult(BaseModel):
|
|||
ok: bool
|
||||
tool_count: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class McpServerImportRequest(BaseModel):
|
||||
# A standard mcpServers JSON config (Claude Desktop / Cursor / Cline / VS Code).
|
||||
config: dict
|
||||
|
||||
|
||||
class McpServerImportResult(BaseModel):
|
||||
created: list[McpServerResponse] = Field(default_factory = list)
|
||||
skipped: list[str] = Field(default_factory = list) # display names skipped as duplicates
|
||||
errors: list[str] = Field(default_factory = list)
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ from core.inference.mcp_client import (
|
|||
probe_timeout,
|
||||
stdio_mcp_enabled,
|
||||
)
|
||||
from core.inference.mcp_config_import import parse_mcp_config
|
||||
from models.mcp_servers import (
|
||||
McpServerCreate,
|
||||
McpServerImportRequest,
|
||||
McpServerImportResult,
|
||||
McpServerProbeResult,
|
||||
McpServerResponse,
|
||||
McpServerTestRequest,
|
||||
|
|
@ -240,6 +243,46 @@ async def refresh_mcp_server_tools(
|
|||
return McpServerProbeResult(ok = True, tool_count = len(tools))
|
||||
|
||||
|
||||
@router.post("/import", response_model = McpServerImportResult)
|
||||
async def import_mcp_servers(
|
||||
payload: McpServerImportRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""Bulk-register servers from a standard mcpServers JSON config (issue
|
||||
#5936). Each entry rides the existing create path: _validate_url applies
|
||||
the same stdio gate (a stdio entry becomes a per-entry error when stdio is
|
||||
off; http still imports), and entries whose url already exists are skipped
|
||||
so re-importing the same file is idempotent. One bad entry never 400s the
|
||||
whole batch -- failures are reported per entry."""
|
||||
entries, errors = parse_mcp_config(payload.config)
|
||||
created: list[McpServerResponse] = []
|
||||
skipped: list[str] = []
|
||||
seen_urls = {row["url"] for row in mcp_servers_db.list_servers()}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
url = _validate_url(entry.url)
|
||||
except HTTPException as exc:
|
||||
errors.append(f"{entry.display_name}: {exc.detail}")
|
||||
continue
|
||||
if url in seen_urls:
|
||||
skipped.append(entry.display_name)
|
||||
continue
|
||||
headers = _normalize_headers(entry.headers)
|
||||
server_id = uuid.uuid4().hex[:16]
|
||||
mcp_servers_db.create_server(
|
||||
id = server_id,
|
||||
display_name = entry.display_name,
|
||||
url = url,
|
||||
headers_json = json.dumps(headers) if headers else None,
|
||||
is_enabled = entry.is_enabled,
|
||||
use_oauth = entry.use_oauth and not is_stdio(url),
|
||||
)
|
||||
seen_urls.add(url)
|
||||
created.append(_row_to_response(mcp_servers_db.get_server(server_id)))
|
||||
|
||||
return McpServerImportResult(created = created, skipped = skipped, errors = errors)
|
||||
|
||||
|
||||
@router.post("/test", response_model = McpServerProbeResult)
|
||||
async def test_mcp_server(
|
||||
payload: McpServerTestRequest, current_subject: str = Depends(get_current_subject)
|
||||
|
|
|
|||
341
studio/backend/tests/test_mcp_config_import.py
Normal file
341
studio/backend/tests/test_mcp_config_import.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""Tests for MCP config-file import (issue #5936).
|
||||
|
||||
Covers the round-trip-safe command join/split inverse (join_stdio_command ↔
|
||||
parse_stdio_command, on both posix and win32 using the issue's Windows
|
||||
fixtures), the pure config parser (parse_mcp_config), and the POST /import
|
||||
route (stdio gate on/off, url dedup, one bad entry not sinking the batch).
|
||||
|
||||
Run from studio/backend: python -m pytest tests/test_mcp_config_import.py -q
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from core.inference import mcp_client
|
||||
from core.inference.mcp_config_import import parse_mcp_config
|
||||
from storage import mcp_servers_db
|
||||
|
||||
|
||||
def _reset_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
|
||||
|
||||
|
||||
def _enable(monkeypatch):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
|
||||
|
||||
|
||||
def _disable(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)
|
||||
|
||||
|
||||
# ── 1. join_stdio_command ↔ parse_stdio_command round-trip ──────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parts",
|
||||
[
|
||||
["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
["python", "-m", "mod", "--name", "a b"],
|
||||
["uvx", "some-server", "--flag"],
|
||||
["/usr/local/bin/my-server"],
|
||||
["mcp-server-sqlite"],
|
||||
],
|
||||
)
|
||||
def test_join_parse_roundtrip_posix(monkeypatch, parts):
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
joined = mcp_client.join_stdio_command(parts)
|
||||
assert mcp_client.parse_stdio_command(joined) == parts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parts",
|
||||
[
|
||||
# Issue #5936's literal Windows examples: absolute .exe with a path, and
|
||||
# backslash drive/dir args must survive the join→split round-trip intact.
|
||||
[
|
||||
"C:\\Users\\user\\Documents\\Office-Word-MCP-Server\\.venv\\Scripts\\python.exe",
|
||||
"C:\\Users\\user\\Documents\\Office-Word-MCP-Server\\word_mcp_server.py",
|
||||
],
|
||||
[
|
||||
"node",
|
||||
"C:\\Users\\user\\Documents\\DesktopCommanderMCP\\dist\\index.js",
|
||||
"--no-onboarding",
|
||||
],
|
||||
[
|
||||
"node",
|
||||
"C:\\Users\\user\\AppData\\Roaming\\npm\\node_modules\\@modelcontextprotocol\\server-filesystem\\dist\\index.js",
|
||||
"D:\\",
|
||||
"O:\\",
|
||||
],
|
||||
# A command path with spaces is the case that actually needs quoting.
|
||||
["C:\\Program Files\\node\\node.exe", "server.js"],
|
||||
["C:\\Program Files\\Foo\\", "server.js"],
|
||||
["C:\\Program Files\\Foo\\", '{"foo":"bar"}'],
|
||||
["'C:\\Program Files\\node\\node.exe'", "server.js"],
|
||||
["node", "O'Reilly"],
|
||||
["node", "C:\\Users\\O'Reilly\\server.js"],
|
||||
["node", "'draft'"],
|
||||
["node", "'open", "close'"],
|
||||
["node", ""],
|
||||
],
|
||||
)
|
||||
def test_join_parse_roundtrip_win32(monkeypatch, parts):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
joined = mcp_client.join_stdio_command(parts)
|
||||
assert mcp_client.parse_stdio_command(joined) == parts
|
||||
|
||||
|
||||
def test_parse_rejects_manual_single_quoted_windows_executable(monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
command = "'C:\\Program Files\\node\\node.exe' server.js"
|
||||
with pytest.raises(ValueError):
|
||||
mcp_client.parse_stdio_command(command)
|
||||
|
||||
|
||||
def test_parse_windows_apostrophes_as_literals(monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
assert mcp_client.parse_stdio_command("node O'Reilly") == ["node", "O'Reilly"]
|
||||
assert mcp_client.parse_stdio_command("node C:\\Users\\O'Reilly\\server.js") == [
|
||||
"node",
|
||||
"C:\\Users\\O'Reilly\\server.js",
|
||||
]
|
||||
assert mcp_client.parse_stdio_command("node 'draft'") == ["node", "'draft'"]
|
||||
assert mcp_client.parse_stdio_command("node 'open close'") == ["node", "'open", "close'"]
|
||||
|
||||
|
||||
def test_parse_rejects_unterminated_windows_double_quote(monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
with pytest.raises(ValueError):
|
||||
mcp_client.parse_stdio_command('node "C:\\path with spaces')
|
||||
|
||||
|
||||
# ── 2. parse_mcp_config ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_stdio_entry():
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"fs": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "server", "/tmp"],
|
||||
"env": {"K": "v"},
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.display_name == "fs"
|
||||
assert entry.is_stdio is True
|
||||
assert entry.headers == {"K": "v"}
|
||||
assert mcp_client.parse_stdio_command(entry.url) == ["npx", "-y", "server", "/tmp"]
|
||||
|
||||
|
||||
def test_parse_remote_entry():
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"remote": {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {"Authorization": "Bearer x"},
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert entries[0].url == "https://example.com/mcp"
|
||||
assert entries[0].is_stdio is False
|
||||
assert entries[0].headers == {"Authorization": "Bearer x"}
|
||||
|
||||
|
||||
def test_parse_preserves_disabled_and_oauth():
|
||||
cfg = {
|
||||
"servers": {
|
||||
"remote": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp",
|
||||
"oauth": {"clientId": "client"},
|
||||
"disabled": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert entries[0].is_enabled is False
|
||||
assert entries[0].use_oauth is True
|
||||
|
||||
|
||||
def test_parse_accepts_cline_streamable_http_alias():
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"remote": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://example.com/mcp",
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert entries[0].url == "https://example.com/mcp"
|
||||
assert entries[0].is_stdio is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"server",
|
||||
[
|
||||
{"command": "node", "args": ["server.js"], "cwd": "/tmp/server"},
|
||||
{"command": "node", "args": ["server.js"], "envFile": ".env"},
|
||||
{"command": "node", "args": ["server.js"], "env": {"API_KEY": "${input:api-key}"}},
|
||||
{"command": "node", "args": ["${workspaceFolder}/server.js"]},
|
||||
{"command": "node", "args": ["server.js"], "env": {"HTTP_PROXY": None}},
|
||||
{"command": "node", "args": ["server.js"], "sandboxEnabled": True},
|
||||
{"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer ${input:token}"}},
|
||||
{"url": "https://example.com/mcp", "headers": {"Authorization": None}},
|
||||
{"type": "http", "url": "https://example.com/sse"},
|
||||
{"type": "http", "url": "https://example.com/sse "},
|
||||
{"type": "streamableHttp", "url": "https://example.com/sse"},
|
||||
{"url": "https://example.com/mcp", "timeout": 120},
|
||||
{"url": "https://example.com/mcp", "timeoutMs": 120000},
|
||||
{"url": "https://example.com/mcp", "timeoutSeconds": 120},
|
||||
{"type": "sse", "url": "https://example.com/custom"},
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_unrepresentable_imports(server):
|
||||
entries, errors = parse_mcp_config({"servers": {"bad": server}})
|
||||
assert entries == []
|
||||
assert len(errors) == 1
|
||||
|
||||
|
||||
def test_servers_alias_key():
|
||||
# VS Code uses "servers" instead of "mcpServers".
|
||||
cfg = {"servers": {"fs": {"command": "node", "args": ["x.js"]}}}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert len(entries) == 1
|
||||
|
||||
|
||||
def test_env_and_args_values_coerced_to_str():
|
||||
cfg = {"mcpServers": {"fs": {"command": "node", "args": [8080], "env": {"PORT": 8080}}}}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert entries[0].headers == {"PORT": "8080"}
|
||||
assert mcp_client.parse_stdio_command(entries[0].url) == ["node", "8080"]
|
||||
|
||||
|
||||
def test_args_optional():
|
||||
cfg = {"mcpServers": {"sqlite": {"command": "mcp-server-sqlite"}}}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert errors == []
|
||||
assert entries[0].url == "mcp-server-sqlite"
|
||||
assert entries[0].headers is None
|
||||
|
||||
|
||||
def test_bad_entry_does_not_sink_batch():
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"good": {"command": "node", "args": ["x.js"]},
|
||||
"both": {"command": "node", "url": "https://x/mcp"},
|
||||
"neither": {"name": "oops"},
|
||||
"bad_args": {"command": "node", "args": "x.js"},
|
||||
"bad_env": {"command": "node", "env": ["NOT", "A", "DICT"]},
|
||||
}
|
||||
}
|
||||
entries, errors = parse_mcp_config(cfg)
|
||||
assert {e.display_name for e in entries} == {"good"}
|
||||
assert len(errors) == 4
|
||||
|
||||
|
||||
def test_not_a_dict():
|
||||
entries, errors = parse_mcp_config([])
|
||||
assert entries == []
|
||||
assert len(errors) == 1
|
||||
|
||||
|
||||
def test_missing_servers_key():
|
||||
entries, errors = parse_mcp_config({"foo": {}})
|
||||
assert entries == []
|
||||
assert len(errors) == 1
|
||||
|
||||
|
||||
def test_servers_alias_error_names_actual_key():
|
||||
entries, errors = parse_mcp_config({"servers": []})
|
||||
assert entries == []
|
||||
assert errors == ["'servers' must be an object mapping name -> server."]
|
||||
|
||||
|
||||
# ── 3. POST /import route ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_import_route_creates_and_dedups(tmp_path, monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from models.mcp_servers import McpServerImportRequest
|
||||
import routes.mcp_servers as routes_mcp
|
||||
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
_enable(monkeypatch)
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"fs": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "server", "/tmp"],
|
||||
"env": {"API_KEY": "sk"},
|
||||
},
|
||||
"remote": {"url": "https://example.com/mcp"},
|
||||
"oauth": {
|
||||
"type": "http",
|
||||
"url": "https://auth.example.com/mcp",
|
||||
"oauth": {"clientId": "client"},
|
||||
},
|
||||
"disabled": {
|
||||
"url": "https://disabled.example.com/mcp",
|
||||
"disabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
res = asyncio.run(
|
||||
routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
|
||||
)
|
||||
assert res.errors == []
|
||||
assert res.skipped == []
|
||||
assert {c.display_name for c in res.created} == {"fs", "remote", "oauth", "disabled"}
|
||||
fs = next(c for c in res.created if c.display_name == "fs")
|
||||
assert fs.headers == {"API_KEY": "sk"}
|
||||
assert fs.use_oauth is False
|
||||
assert fs.is_enabled is True
|
||||
oauth = next(c for c in res.created if c.display_name == "oauth")
|
||||
assert oauth.use_oauth is True
|
||||
disabled = next(c for c in res.created if c.display_name == "disabled")
|
||||
assert disabled.is_enabled is False
|
||||
|
||||
# Re-importing the same config skips both by url.
|
||||
res2 = asyncio.run(
|
||||
routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
|
||||
)
|
||||
assert res2.created == []
|
||||
assert set(res2.skipped) == {"fs", "remote", "oauth", "disabled"}
|
||||
|
||||
|
||||
def test_import_route_gates_stdio_when_disabled(tmp_path, monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from models.mcp_servers import McpServerImportRequest
|
||||
import routes.mcp_servers as routes_mcp
|
||||
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
_disable(monkeypatch)
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"fs": {"command": "npx", "args": ["server"]},
|
||||
"remote": {"url": "https://example.com/mcp"},
|
||||
}
|
||||
}
|
||||
res = asyncio.run(
|
||||
routes_mcp.import_mcp_servers(McpServerImportRequest(config = cfg), current_subject = "u")
|
||||
)
|
||||
# Remote still imports; the stdio entry is rejected per-entry (gate off).
|
||||
assert {c.display_name for c in res.created} == {"remote"}
|
||||
assert any("fs" in err for err in res.errors)
|
||||
assert len(mcp_servers_db.list_servers()) == 1
|
||||
|
|
@ -21,6 +21,12 @@ export interface McpServerProbeResult {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
export interface McpServerImportResult {
|
||||
created: McpServerConfig[];
|
||||
skipped: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function parseErrorText(status: number, body: unknown): string {
|
||||
if (body && typeof body === "object") {
|
||||
const { detail, message } = body as { detail?: unknown; message?: unknown };
|
||||
|
|
@ -114,3 +120,12 @@ export function testMcpServer(payload: {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk-import servers from a standard mcpServers JSON config (Claude Desktop,
|
||||
// Cursor, Cline, VS Code). The backend skips duplicates and reports per-entry
|
||||
// errors instead of failing the whole batch.
|
||||
export function importMcpServers(
|
||||
config: unknown,
|
||||
): Promise<McpServerImportResult> {
|
||||
return mcpRequest("/import", { method: "POST", body: { config } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Delete02Icon, Edit03Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { RefreshCwIcon, UploadIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type McpServerConfig,
|
||||
createMcpServer,
|
||||
deleteMcpServer,
|
||||
importMcpServers,
|
||||
listMcpServers,
|
||||
refreshMcpServerTools,
|
||||
testMcpServer,
|
||||
|
|
@ -194,7 +195,9 @@ export function ChatMcpServersDialog({
|
|||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -315,6 +318,49 @@ export function ChatMcpServersDialog({
|
|||
}
|
||||
}
|
||||
|
||||
async function onImportFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // let the user re-pick the same file later
|
||||
if (!file) return;
|
||||
let config: unknown;
|
||||
try {
|
||||
config = JSON.parse(await file.text());
|
||||
} catch {
|
||||
toast.error("Invalid JSON file");
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await importMcpServers(config);
|
||||
const parts = [`${result.created.length} added`];
|
||||
if (result.skipped.length) parts.push(`${result.skipped.length} skipped`);
|
||||
if (result.errors.length) {
|
||||
parts.push(
|
||||
`${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
const summary = parts.join(", ");
|
||||
if (result.errors.length) {
|
||||
toast.warning(summary, {
|
||||
description: (
|
||||
<div className="whitespace-pre-line">
|
||||
{result.errors.slice(0, 5).join("\n")}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.success(summary);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error("Import failed", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(server: McpServerConfig) {
|
||||
const ok = window.confirm(`Delete MCP server "${server.display_name}"?`);
|
||||
if (!ok) return;
|
||||
|
|
@ -385,9 +431,35 @@ export function ChatMcpServersDialog({
|
|||
Register remote (HTTP) or local (stdio command) MCP servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={onImportFile}
|
||||
/>
|
||||
|
||||
{showForm ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{view.kind === "create" && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Import servers from a config file.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
title="Import servers from a mcpServers JSON config (Claude Desktop, Cursor, VS Code…)"
|
||||
>
|
||||
{importing ? <Spinner /> : <UploadIcon size={14} />}
|
||||
Import config
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-display-name">Display name</Label>
|
||||
<Input
|
||||
|
|
@ -467,7 +539,17 @@ export function ChatMcpServersDialog({
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
title="Import servers from a mcpServers JSON config (Claude Desktop, Cursor, VS Code…)"
|
||||
>
|
||||
{importing ? <Spinner /> : <UploadIcon size={14} />}
|
||||
Import config
|
||||
</Button>
|
||||
<Button size="sm" onClick={startCreate}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} size={14} />
|
||||
Add server
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue