Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Roland Tannous
0c3f065c23
Merge branch 'main' into feature/mcp-config-file 2026-06-03 09:13:42 +04:00
pre-commit-ci[bot]
160093fb54 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-06-02 08:18:53 +00:00
Roland Tannous
0fdf7e32d0 studio: import MCP servers from a config file 2026-06-02 10:27:06 +04:00
7 changed files with 482 additions and 3 deletions

View file

@ -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

View file

@ -0,0 +1,97 @@
# 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

View file

@ -44,3 +44,16 @@ 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)

View file

@ -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,
@ -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,

View file

@ -0,0 +1,240 @@
"""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

View file

@ -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 } });
}

View file

@ -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<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);
@ -321,6 +324,43 @@ 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: 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({
</div>
) : (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={onImportFile}
/>
<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