e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""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
|