From 0fdf7e32d02040950dab180ad0454829ea9622f0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 2 Jun 2026 10:27:06 +0400 Subject: [PATCH 1/2] studio: import MCP servers from a config file --- studio/backend/core/inference/mcp_client.py | 13 + .../core/inference/mcp_config_import.py | 95 +++++++ studio/backend/models/mcp_servers.py | 11 + studio/backend/routes/mcp_servers.py | 44 ++++ .../backend/tests/test_mcp_config_import.py | 238 ++++++++++++++++++ .../src/features/chat/api/mcp-servers-api.ts | 15 ++ .../features/chat/chat-mcp-servers-dialog.tsx | 63 ++++- 7 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 studio/backend/core/inference/mcp_config_import.py create mode 100644 studio/backend/tests/test_mcp_config_import.py diff --git a/studio/backend/core/inference/mcp_client.py b/studio/backend/core/inference/mcp_client.py index 2ed1a630dc..0637788095 100644 --- a/studio/backend/core/inference/mcp_client.py +++ b/studio/backend/core/inference/mcp_client.py @@ -41,6 +41,19 @@ def parse_stdio_command(address: str) -> list[str]: return parts +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: """stdio MCP servers spawn local processes as the backend user (and bypass the python/terminal sandbox), so they are only allowed when the backend diff --git a/studio/backend/core/inference/mcp_config_import.py b/studio/backend/core/inference/mcp_config_import.py new file mode 100644 index 0000000000..c53bf8836d --- /dev/null +++ b/studio/backend/core/inference/mcp_config_import.py @@ -0,0 +1,95 @@ +# 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) + + +@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 + + +def _coerce_str_dict(value: dict) -> dict[str, str]: + return {str(k): str(v) for k, v in value.items()} + + +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." + + 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." + 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." + 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), None + + url = spec["url"] + if not isinstance(url, str): + return None, f"{label}: 'url' must be a string." + 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." + headers = _coerce_str_dict(headers_raw) if headers_raw else None + return ParsedMcpEntry(label, url.strip(), headers, False), 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 = config.get("mcpServers") + if servers is None: + servers = config.get("servers") + if servers is None: + return [], ["Config has no 'mcpServers' (or 'servers') object."] + if not isinstance(servers, dict): + return [], ["'mcpServers' 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 diff --git a/studio/backend/models/mcp_servers.py b/studio/backend/models/mcp_servers.py index c696eb0faa..606c2423bf 100644 --- a/studio/backend/models/mcp_servers.py +++ b/studio/backend/models/mcp_servers.py @@ -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) diff --git a/studio/backend/routes/mcp_servers.py b/studio/backend/routes/mcp_servers.py index 200c0ed452..a1c1fa463e 100644 --- a/studio/backend/routes/mcp_servers.py +++ b/studio/backend/routes/mcp_servers.py @@ -18,8 +18,11 @@ 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, @@ -247,6 +250,47 @@ 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 = True, + use_oauth = False, + ) + 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, diff --git a/studio/backend/tests/test_mcp_config_import.py b/studio/backend/tests/test_mcp_config_import.py new file mode 100644 index 0000000000..693cec1281 --- /dev/null +++ b/studio/backend/tests/test_mcp_config_import.py @@ -0,0 +1,238 @@ +"""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"], + ], +) +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 + + +# ── 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_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 + + +# ── 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"}, + } + } + 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"} + 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 + + # 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"} + + +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 diff --git a/studio/frontend/src/features/chat/api/mcp-servers-api.ts b/studio/frontend/src/features/chat/api/mcp-servers-api.ts index be88d12664..0dc0719da4 100644 --- a/studio/frontend/src/features/chat/api/mcp-servers-api.ts +++ b/studio/frontend/src/features/chat/api/mcp-servers-api.ts @@ -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 { + return mcpRequest("/import", { method: "POST", body: { config } }); +} diff --git a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx index 93c4c8972c..11cdc232d8 100644 --- a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx @@ -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, @@ -198,7 +199,9 @@ export function ChatMcpServersDialog({ const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [importing, setImporting] = useState(false); const [refreshingId, setRefreshingId] = useState(null); + const fileInputRef = useRef(null); const refresh = useCallback(async () => { setLoading(true); @@ -321,6 +324,43 @@ export function ChatMcpServersDialog({ } } + async function onImportFile(e: ChangeEvent) { + 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: result.errors.slice(0, 5).join("\n") }); + } 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; @@ -473,7 +513,24 @@ export function ChatMcpServersDialog({ ) : (
-
+
+ +