601 lines
22 KiB
Python
601 lines
22 KiB
Python
"""Tests for utility functions in commands/mcp.py."""
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import ClassVar
|
|
|
|
import pytest
|
|
from fastmcp import FastMCP
|
|
|
|
from mcp_proxy.commands import mcp as mcp_command
|
|
from mcp_proxy.commands.mcp import (
|
|
HIDDEN_FROM_LISTING_TAG,
|
|
HideTaggedToolsMiddleware,
|
|
_find_free_port,
|
|
_iter_packages,
|
|
_validate_tool_sets,
|
|
_wait_for_port,
|
|
build_proxy_app,
|
|
discover_mcp_servers,
|
|
)
|
|
from mcp_proxy.service import McpConfig, McpService, resolve_command
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_free_port
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindFreePort:
|
|
def test_returns_int(self):
|
|
port = _find_free_port()
|
|
assert isinstance(port, int)
|
|
|
|
def test_port_is_in_valid_range(self):
|
|
port = _find_free_port()
|
|
assert 1024 <= port <= 65535
|
|
|
|
def test_returns_different_ports(self):
|
|
ports = {_find_free_port() for _ in range(5)}
|
|
# At least 2 distinct ports (extremely likely)
|
|
assert len(ports) >= 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _wait_for_port
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWaitForPort:
|
|
def test_returns_true_when_port_is_listening(self):
|
|
# Start a simple server
|
|
server = HTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
thread.start()
|
|
try:
|
|
assert _wait_for_port(port, timeout=5.0) is True
|
|
finally:
|
|
server.server_close()
|
|
|
|
def test_returns_false_when_port_not_listening(self):
|
|
# Find a free port and don't listen on it
|
|
port = _find_free_port()
|
|
assert _wait_for_port(port, timeout=0.5) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# McpConfig & discover_mcp_servers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMcpConfig:
|
|
def test_basic_config(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/my-service"),
|
|
{"run": {"command": "node", "args": ["index.js"]}},
|
|
)
|
|
assert cfg.name == "my-service"
|
|
assert len(cfg.run) == 1
|
|
assert cfg.run[0].command == "node"
|
|
assert cfg.run[0].args == ["index.js"]
|
|
assert cfg.setup == []
|
|
|
|
def test_run_list(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{"run": [{"command": "build"}, {"command": "node", "args": ["index.js"]}]},
|
|
)
|
|
assert len(cfg.run) == 2
|
|
assert cfg.run[0].command == "build"
|
|
assert cfg.run[1].command == "node"
|
|
|
|
def test_missing_run_raises(self):
|
|
with pytest.raises(ValueError, match="'run' is required"):
|
|
McpConfig.from_mcp_json(Path("/tmp/svc"), {})
|
|
|
|
def test_custom_name(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/my-service"),
|
|
{"name": "Custom", "run": {"command": "python"}},
|
|
)
|
|
assert cfg.name == "Custom"
|
|
|
|
def test_setup_single_dict(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{
|
|
"run": {"command": "node"},
|
|
"setup": {"command": "npm", "args": ["run", "build"]},
|
|
},
|
|
)
|
|
assert len(cfg.setup) == 1
|
|
assert cfg.setup[0].command == "npm"
|
|
|
|
def test_setup_list(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{
|
|
"run": {"command": "node"},
|
|
"setup": [{"command": "a"}, {"command": "b"}],
|
|
},
|
|
)
|
|
assert len(cfg.setup) == 2
|
|
|
|
def test_cwd_override(self, tmp_path):
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path / "svc",
|
|
{"run": {"command": "node"}, "cwd": "subdir"},
|
|
)
|
|
assert cfg.cwd == (tmp_path / "svc" / "subdir").resolve()
|
|
|
|
def test_install_single_dict(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{
|
|
"run": {"command": "node"},
|
|
"install": {"command": "uv", "args": ["sync", "--package", "foo"]},
|
|
},
|
|
)
|
|
assert len(cfg.install) == 1
|
|
assert cfg.install[0].command == "uv"
|
|
assert cfg.install[0].args == ["sync", "--package", "foo"]
|
|
|
|
def test_install_defaults_empty(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{"run": {"command": "node"}},
|
|
)
|
|
assert cfg.install == []
|
|
|
|
def test_toolsets_parsed(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{
|
|
"run": {"command": "node"},
|
|
"toolsets": {"read": ["t1", "t2"], "write": ["t3"]},
|
|
},
|
|
)
|
|
assert cfg.toolsets == {"read": ["t1", "t2"], "write": ["t3"]}
|
|
|
|
def test_toolsets_defaults_empty(self):
|
|
cfg = McpConfig.from_mcp_json(
|
|
Path("/tmp/svc"),
|
|
{"run": {"command": "node"}},
|
|
)
|
|
assert cfg.toolsets == {}
|
|
|
|
|
|
class TestIterPackages:
|
|
"""Iteration is the discovery primitive — it returns every package
|
|
regardless of any tool_sets filter. ``gen`` uses this directly."""
|
|
|
|
def test_iter_packages_returns_each(self, tmp_path):
|
|
pkg = tmp_path / "svc1"
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {"command": "node"}}))
|
|
|
|
servers = _iter_packages(tmp_path)
|
|
assert len(servers) == 1
|
|
assert servers[0].name == "svc1"
|
|
|
|
def test_exits_on_missing_run_command(self, tmp_path):
|
|
pkg = tmp_path / "bad"
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {}}))
|
|
|
|
with pytest.raises(SystemExit):
|
|
_iter_packages(tmp_path)
|
|
|
|
def test_exits_on_invalid_json(self, tmp_path):
|
|
pkg = tmp_path / "broken"
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text("not json")
|
|
|
|
with pytest.raises(SystemExit):
|
|
_iter_packages(tmp_path)
|
|
|
|
def test_exits_on_nonexistent_root(self, tmp_path):
|
|
with pytest.raises(SystemExit):
|
|
_iter_packages(tmp_path / "nope")
|
|
|
|
def test_sorted_by_name(self, tmp_path):
|
|
for name in ("zebra", "alpha", "middle"):
|
|
pkg = tmp_path / name
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {"command": "python"}}))
|
|
|
|
servers = _iter_packages(tmp_path)
|
|
names = [s.name for s in servers]
|
|
assert names == ["alpha", "middle", "zebra"]
|
|
|
|
|
|
class TestDiscoverMcpServers:
|
|
def test_filter_by_namespaced_toolset(self, tmp_path):
|
|
"""Only servers declaring the requested namespaced toolset are returned."""
|
|
(tmp_path / "alpha").mkdir()
|
|
(tmp_path / "alpha" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t1"]}})
|
|
)
|
|
(tmp_path / "beta").mkdir()
|
|
(tmp_path / "beta" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"write": ["t2"]}})
|
|
)
|
|
(tmp_path / "gamma").mkdir()
|
|
(tmp_path / "gamma" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t3"]}})
|
|
)
|
|
|
|
servers = discover_mcp_servers(tmp_path, tool_sets=["alpha_read", "gamma_read"])
|
|
assert [s.name for s in servers] == ["alpha", "gamma"]
|
|
|
|
def test_no_filter_returns_nothing(self, tmp_path):
|
|
"""tool_sets=None / [] / [""] all mean 'no servers' — callers must opt in
|
|
explicitly. Use _iter_packages when you need every package."""
|
|
for name in ("alpha", "beta"):
|
|
pkg = tmp_path / name
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {"command": "node"}}))
|
|
|
|
assert discover_mcp_servers(tmp_path, tool_sets=None) == []
|
|
assert discover_mcp_servers(tmp_path, tool_sets=[]) == []
|
|
assert discover_mcp_servers(tmp_path, tool_sets=[""]) == []
|
|
|
|
def test_filter_multiple_namespaced_toolsets(self, tmp_path):
|
|
"""Servers declaring any of the requested namespaced toolsets are included."""
|
|
(tmp_path / "alpha").mkdir()
|
|
(tmp_path / "alpha" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t1"]}})
|
|
)
|
|
(tmp_path / "beta").mkdir()
|
|
(tmp_path / "beta" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"write": ["t2"]}})
|
|
)
|
|
(tmp_path / "gamma").mkdir()
|
|
(tmp_path / "gamma" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"debug": ["t3"]}})
|
|
)
|
|
|
|
servers = discover_mcp_servers(tmp_path, tool_sets=["alpha_read", "beta_write"])
|
|
assert [s.name for s in servers] == ["alpha", "beta"]
|
|
|
|
def test_filter_namespaced_toolset(self, tmp_path):
|
|
"""Namespaced form 'pkg_toolset' selects only that package+toolset."""
|
|
(tmp_path / "google_mail").mkdir()
|
|
(tmp_path / "google_mail" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t1"], "write": ["t2"]}})
|
|
)
|
|
(tmp_path / "slack").mkdir()
|
|
(tmp_path / "slack" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t3"], "write": ["t4"]}})
|
|
)
|
|
(tmp_path / "jira").mkdir()
|
|
(tmp_path / "jira" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}}) # no toolsets
|
|
)
|
|
|
|
# Only google_mail should be found; slack has no google_mail_read toolset
|
|
servers = discover_mcp_servers(tmp_path, tool_sets=["google_mail_read"])
|
|
assert [s.name for s in servers] == ["google_mail"]
|
|
|
|
def test_returns_mcp_service_objects(self, tmp_path):
|
|
"""discover_mcp_servers returns McpService instances wrapping McpConfig."""
|
|
pkg = tmp_path / "svc"
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t"]}}))
|
|
|
|
servers = discover_mcp_servers(tmp_path, tool_sets=["svc_read"])
|
|
assert len(servers) == 1
|
|
assert isinstance(servers[0], McpService)
|
|
assert isinstance(servers[0].config, McpConfig)
|
|
|
|
def test_bare_package_name_is_rejected(self, tmp_path):
|
|
"""Bare package names are not valid tool_sets — they must be namespaced
|
|
toolset names (``google_mail_read``), so validation rejects them."""
|
|
(tmp_path / "google_mail").mkdir()
|
|
(tmp_path / "google_mail" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "node"}, "toolsets": {"read": ["t1"]}})
|
|
)
|
|
# "google_mail" is a package name, not a toolset name — unknown tool set.
|
|
with pytest.raises(SystemExit):
|
|
discover_mcp_servers(tmp_path, tool_sets=["google_mail"])
|
|
|
|
def _core_and_shim(self, tmp_path):
|
|
"""Create fake ``core`` and ``syntara`` (compat shim) packages."""
|
|
(tmp_path / "core").mkdir()
|
|
(tmp_path / "core" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "python"}, "toolsets": {"read": ["readFile"]}})
|
|
)
|
|
(tmp_path / "syntara").mkdir()
|
|
(tmp_path / "syntara" / "mcp.json").write_text(
|
|
json.dumps({"run": {"command": "python"}, "toolsets": {"read": ["readFile"]}})
|
|
)
|
|
|
|
def test_core_and_syntara_shim_together_warns(self, tmp_path, caplog):
|
|
"""core + the syntara compat shim are redundant but allowed — selecting both
|
|
mounts both surfaces and warns. REMOVE with the syntara package after 2026-06-18."""
|
|
self._core_and_shim(tmp_path)
|
|
with caplog.at_level(logging.WARNING, logger="mcp_proxy"):
|
|
servers = discover_mcp_servers(tmp_path, tool_sets=["core_read", "syntara_read"])
|
|
assert sorted(s.name for s in servers) == ["core", "syntara"]
|
|
assert any("both 'core' and the legacy 'syntara'" in r.message for r in caplog.records)
|
|
|
|
def test_core_alone_is_allowed(self, tmp_path):
|
|
self._core_and_shim(tmp_path)
|
|
assert [s.name for s in discover_mcp_servers(tmp_path, tool_sets=["core_read"])] == ["core"]
|
|
|
|
def test_syntara_shim_alone_is_allowed(self, tmp_path):
|
|
self._core_and_shim(tmp_path)
|
|
assert [s.name for s in discover_mcp_servers(tmp_path, tool_sets=["syntara_read"])] == ["syntara"]
|
|
|
|
|
|
class TestValidateToolSets:
|
|
@staticmethod
|
|
def _write_pkg(root, name, toolsets):
|
|
"""Materialize a minimal ``packages/<name>/mcp.json`` declaring *toolsets*."""
|
|
pkg = root / name
|
|
pkg.mkdir()
|
|
(pkg / "mcp.json").write_text(json.dumps({"run": {"command": "node"}, "toolsets": toolsets}))
|
|
|
|
def test_valid_tool_sets_pass(self, tmp_path):
|
|
"""Namespaced tool set names that a package declares do not raise."""
|
|
self._write_pkg(tmp_path, "google_mail", {"read": []})
|
|
self._write_pkg(tmp_path, "slack", {"state": []})
|
|
_validate_tool_sets(["google_mail_read", "slack_state"], tmp_path)
|
|
|
|
def test_unknown_tool_set_exits(self, tmp_path):
|
|
"""Unknown tool set names cause sys.exit."""
|
|
self._write_pkg(tmp_path, "slack", {"read": []})
|
|
with pytest.raises(SystemExit):
|
|
_validate_tool_sets(["nonexistent"], tmp_path)
|
|
|
|
def test_partial_unknown_exits(self, tmp_path):
|
|
"""Even one unknown tool set among valid ones causes exit."""
|
|
self._write_pkg(tmp_path, "slack", {"read": []})
|
|
with pytest.raises(SystemExit):
|
|
_validate_tool_sets(["slack_read", "bad_name"], tmp_path)
|
|
|
|
def test_empty_request_passes(self, tmp_path):
|
|
"""No requested tool sets is vacuously valid (nothing unknown)."""
|
|
self._write_pkg(tmp_path, "slack", {"read": []})
|
|
_validate_tool_sets([], tmp_path) # should not raise
|
|
|
|
|
|
class TestBuildProxyApp:
|
|
def test_exits_when_selected_service_fails_startup(self, tmp_path, monkeypatch, caplog):
|
|
"""Requested services that die during startup abort the whole proxy."""
|
|
|
|
class FakeProc:
|
|
returncode = 7
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def terminate(self):
|
|
pass
|
|
|
|
class FakeService:
|
|
name = "jira"
|
|
secrets: ClassVar[list[str]] = []
|
|
config = SimpleNamespace(startup_timeout=0.1)
|
|
|
|
def run_install(self, env):
|
|
return True
|
|
|
|
def run_setup(self, env):
|
|
return True
|
|
|
|
def run_pre_steps(self, env):
|
|
return True
|
|
|
|
def start_service(self, env, port, proxy_token, command_prefix=None):
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(
|
|
"mcp_proxy.commands.mcp.discover_mcp_servers",
|
|
lambda packages_root, tool_sets=None: [FakeService()],
|
|
)
|
|
monkeypatch.setattr(
|
|
"mcp_proxy.commands.mcp._build_subprocess_env",
|
|
lambda base_dir, server_name, declared_secrets=(): {},
|
|
)
|
|
|
|
with pytest.raises(SystemExit):
|
|
build_proxy_app(tmp_path, tmp_path, tool_sets=["jira_read"])
|
|
|
|
assert "Aborting startup because requested MCP services failed to start" in caplog.text
|
|
assert "jira (exited immediately with code 7)" in caplog.text
|
|
|
|
|
|
class TestRunViewerStartup:
|
|
class FakeApp:
|
|
def __init__(self):
|
|
self.run_calls = []
|
|
|
|
def custom_route(self, *_args, **_kwargs):
|
|
def decorator(fn):
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
def run(self, **kwargs):
|
|
self.run_calls.append(kwargs)
|
|
|
|
def _stub_proxy_run(self, monkeypatch, tmp_path):
|
|
app = self.FakeApp()
|
|
monkeypatch.setenv("WORLDBENCH_ROOT", str(tmp_path))
|
|
monkeypatch.setenv("WORLDBENCH_PACKAGES_ROOT", str(tmp_path))
|
|
monkeypatch.delenv("WORLDBENCH_TOOL_SETS", raising=False)
|
|
monkeypatch.delenv("PORT", raising=False)
|
|
monkeypatch.setattr(mcp_command, "install_crash_handlers", lambda: None)
|
|
monkeypatch.setattr(mcp_command, "build_proxy_app", lambda **_kwargs: app)
|
|
monkeypatch.setattr(mcp_command, "_assert_local_tools_async", lambda _app: None)
|
|
monkeypatch.setattr(mcp_command, "_kill_child_processes", lambda: None)
|
|
return app
|
|
|
|
def test_does_not_start_viewer_when_viewer_port_is_unset(self, tmp_path, monkeypatch):
|
|
app = self._stub_proxy_run(monkeypatch, tmp_path)
|
|
viewer_calls = []
|
|
monkeypatch.delenv("VIEWER_PORT", raising=False)
|
|
monkeypatch.setattr(mcp_command, "_start_viewer_server", lambda host, port: viewer_calls.append((host, port)))
|
|
|
|
mcp_command.run(method="stdio")
|
|
|
|
assert viewer_calls == []
|
|
assert app.run_calls == [{"show_banner": False}]
|
|
|
|
def test_starts_viewer_when_viewer_port_is_set(self, tmp_path, monkeypatch):
|
|
app = self._stub_proxy_run(monkeypatch, tmp_path)
|
|
viewer_calls = []
|
|
monkeypatch.setenv("VIEWER_PORT", "8765")
|
|
monkeypatch.setattr(mcp_command, "_start_viewer_server", lambda host, port: viewer_calls.append((host, port)))
|
|
|
|
mcp_command.run(method="stdio")
|
|
|
|
assert viewer_calls == [("0.0.0.0", 8765)]
|
|
assert app.run_calls == [{"show_banner": False}]
|
|
|
|
|
|
class TestHideTaggedToolsMiddleware:
|
|
"""The proxy's un-namespaced ``export_state`` / ``import_state`` are
|
|
infrastructure for the grading harness: they must remain callable so
|
|
snapshots round-trip, but they must NOT appear in ``tools/list`` (an
|
|
agent shouldn't even know they exist).
|
|
"""
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tagged_tools_are_hidden_from_list_but_callable(self):
|
|
app = FastMCP("test")
|
|
app.add_middleware(HideTaggedToolsMiddleware())
|
|
|
|
@app.tool(name="visible")
|
|
async def visible() -> dict:
|
|
return {"v": True}
|
|
|
|
@app.tool(name="hidden", tags={HIDDEN_FROM_LISTING_TAG})
|
|
async def hidden() -> dict:
|
|
return {"h": True}
|
|
|
|
listed = {t.name for t in await app.list_tools()}
|
|
assert listed == {"visible"}, f"hidden tool leaked into list_tools: {listed}"
|
|
|
|
# Hidden tool must still be callable — that's the point.
|
|
hidden_result = await app.call_tool("hidden", {})
|
|
assert hidden_result.structured_content == {"h": True}
|
|
|
|
visible_result = await app.call_tool("visible", {})
|
|
assert visible_result.structured_content == {"v": True}
|
|
|
|
@pytest.fixture
|
|
def anyio_backend(self):
|
|
return "asyncio"
|
|
|
|
|
|
class TestMcpServiceLifecycle:
|
|
@pytest.fixture
|
|
def env(self):
|
|
"""Return a minimal env dict with PATH so subprocesses can find python."""
|
|
import os
|
|
|
|
return dict(os.environ)
|
|
|
|
def test_run_install_executes_steps(self, tmp_path, env):
|
|
"""Install steps are executed and create side effects."""
|
|
marker = tmp_path / "installed.txt"
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path,
|
|
{
|
|
"run": {"command": "python", "args": ["-c", "pass"]},
|
|
"install": {"command": "python", "args": ["-c", f"open('{marker}', 'w').write('ok')"]},
|
|
},
|
|
)
|
|
svc = McpService(cfg)
|
|
assert svc.run_install(env) is True
|
|
assert marker.read_text() == "ok"
|
|
|
|
def test_run_install_returns_true_when_empty(self, tmp_path, env):
|
|
"""No install steps means success."""
|
|
cfg = McpConfig.from_mcp_json(tmp_path, {"run": {"command": "python"}})
|
|
svc = McpService(cfg)
|
|
assert svc.run_install(env) is True
|
|
|
|
def test_run_install_returns_false_on_failure(self, tmp_path, env):
|
|
"""Failed install step returns False."""
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path,
|
|
{
|
|
"run": {"command": "python"},
|
|
"install": {"command": "python", "args": ["-c", "import sys; sys.exit(1)"]},
|
|
},
|
|
)
|
|
svc = McpService(cfg)
|
|
assert svc.run_install(env) is False
|
|
|
|
def test_run_setup_executes_steps(self, tmp_path, env):
|
|
"""Setup steps are executed and create side effects."""
|
|
marker = tmp_path / "setup.txt"
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path,
|
|
{
|
|
"run": {"command": "python"},
|
|
"setup": {"command": "python", "args": ["-c", f"open('{marker}', 'w').write('done')"]},
|
|
},
|
|
)
|
|
svc = McpService(cfg)
|
|
assert svc.run_setup(env) is True
|
|
assert marker.read_text() == "done"
|
|
|
|
def test_run_setup_forwards_env(self, tmp_path, env):
|
|
"""Environment variables are forwarded to setup steps."""
|
|
marker = tmp_path / "env.txt"
|
|
env["MY_VAR"] = "hello"
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path,
|
|
{
|
|
"run": {"command": "python"},
|
|
"setup": {
|
|
"command": "python",
|
|
"args": ["-c", f"import os; open('{marker}', 'w').write(os.environ.get('MY_VAR', 'MISSING'))"],
|
|
},
|
|
},
|
|
)
|
|
svc = McpService(cfg)
|
|
assert svc.run_setup(env) is True
|
|
assert marker.read_text() == "hello"
|
|
|
|
def test_python_steps_use_proxy_interpreter_not_path(self, tmp_path, env):
|
|
# Empty PATH: a bare `python` would be unfindable, so success proves the
|
|
# step ran the absolute sys.executable (the PATH-elimination contract).
|
|
import sys
|
|
|
|
marker = tmp_path / "interp.txt"
|
|
env["PATH"] = ""
|
|
cfg = McpConfig.from_mcp_json(
|
|
tmp_path,
|
|
{
|
|
"run": {"command": "python"},
|
|
"setup": {
|
|
"command": "python",
|
|
"args": ["-c", f"import sys; open('{marker}', 'w').write(sys.executable)"],
|
|
},
|
|
},
|
|
)
|
|
svc = McpService(cfg)
|
|
assert svc.run_setup(env) is True, "bare `python` step failed — interpreter not resolved to an absolute path"
|
|
assert marker.read_text() == sys.executable
|
|
|
|
|
|
class TestResolveCommand:
|
|
def test_bare_python_resolves_to_sys_executable(self):
|
|
import sys
|
|
|
|
assert resolve_command("python") == sys.executable
|
|
assert resolve_command("python3") == sys.executable
|
|
|
|
def test_non_python_commands_pass_through(self):
|
|
for command in ("node", "bash", "npm", "uv", "/opt/venv/bin/python", "frappe-bench/env/bin/python"):
|
|
assert resolve_command(command) == command
|