184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
|
|
"""Tests for the setup pass the proxy runs during ``mcp`` startup.
|
||
|
|
|
||
|
|
There is no standalone setup command: ``build_proxy_app`` discovers MCP
|
||
|
|
servers and runs each one's ``install`` then ``setup`` hook from
|
||
|
|
``mcp.json`` (forwarding WORLDBENCH_* env vars) before starting the
|
||
|
|
server. Servers are selected by WORLDBENCH_TOOL_SETS: only servers whose
|
||
|
|
mcp.json declares at least one of the requested namespaced toolsets are
|
||
|
|
included. An empty / unset value runs no setup hooks.
|
||
|
|
|
||
|
|
These tests exercise that same discover -> install -> setup pass in
|
||
|
|
process, using the exact primitives ``build_proxy_app`` calls.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
from mcp_proxy.commands.mcp import _build_subprocess_env, discover_mcp_servers
|
||
|
|
|
||
|
|
|
||
|
|
class SetupCommandTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp_dir = tempfile.mkdtemp()
|
||
|
|
self.packages_root = Path(self.temp_dir) / "packages"
|
||
|
|
self.packages_root.mkdir()
|
||
|
|
|
||
|
|
def tearDown(self):
|
||
|
|
shutil.rmtree(self.temp_dir)
|
||
|
|
|
||
|
|
def _create_server(self, name, setup_hook=None, toolsets=None):
|
||
|
|
"""Create a fake MCP server package with an optional setup hook.
|
||
|
|
|
||
|
|
Always declares a ``"default"`` toolset (so :meth:`_run_setup` can
|
||
|
|
opt every package in by default). *toolsets* is merged on top, in
|
||
|
|
case a test wants to declare additional toolsets to filter against.
|
||
|
|
"""
|
||
|
|
pkg_dir = self.packages_root / name
|
||
|
|
pkg_dir.mkdir(parents=True)
|
||
|
|
|
||
|
|
config = {"run": {"command": "python", "args": ["-c", "pass"]}}
|
||
|
|
if setup_hook is not None:
|
||
|
|
config["setup"] = setup_hook
|
||
|
|
config["toolsets"] = {"default": [], **(toolsets or {})}
|
||
|
|
|
||
|
|
(pkg_dir / "mcp.json").write_text(json.dumps(config))
|
||
|
|
return pkg_dir
|
||
|
|
|
||
|
|
def _run_setup(self, env_overrides=None):
|
||
|
|
"""Run the startup setup pass in process and return the failed servers.
|
||
|
|
|
||
|
|
Mirrors the discover -> install -> setup loop ``build_proxy_app``
|
||
|
|
runs before booting (the only place setup hooks fire now). Returns
|
||
|
|
the list of server names whose install or setup hook failed — empty
|
||
|
|
on success.
|
||
|
|
|
||
|
|
Defaults WORLDBENCH_TOOL_SETS to every fake package's ``_default``
|
||
|
|
toolset so each test gets full coverage without spelling them out.
|
||
|
|
Override via *env_overrides* when a test wants narrower filtering.
|
||
|
|
"""
|
||
|
|
default_sets = " ".join(f"{p.name}_default" for p in sorted(self.packages_root.iterdir()) if p.is_dir())
|
||
|
|
env = {
|
||
|
|
"WORLDBENCH_ROOT": self.temp_dir,
|
||
|
|
"WORLDBENCH_PACKAGES_ROOT": str(self.packages_root),
|
||
|
|
"WORLDBENCH_TOOL_SETS": default_sets,
|
||
|
|
}
|
||
|
|
if env_overrides:
|
||
|
|
env.update(env_overrides)
|
||
|
|
|
||
|
|
failed: list[str] = []
|
||
|
|
with mock.patch.dict(os.environ, env):
|
||
|
|
base_dir = Path(os.environ["WORLDBENCH_ROOT"]).resolve()
|
||
|
|
packages_root = Path(os.environ["WORLDBENCH_PACKAGES_ROOT"])
|
||
|
|
tool_sets = os.environ["WORLDBENCH_TOOL_SETS"].split()
|
||
|
|
for cfg in discover_mcp_servers(packages_root, tool_sets=tool_sets):
|
||
|
|
hook_env = _build_subprocess_env(base_dir, cfg.name, declared_secrets=cfg.secrets)
|
||
|
|
if not cfg.run_install(hook_env) or not cfg.run_setup(hook_env):
|
||
|
|
failed.append(cfg.name)
|
||
|
|
return failed
|
||
|
|
|
||
|
|
def test_runs_setup_hook(self):
|
||
|
|
"""Setup hook from mcp.json is executed."""
|
||
|
|
marker = Path(self.temp_dir) / "setup_ran.txt"
|
||
|
|
self._create_server(
|
||
|
|
"svc",
|
||
|
|
setup_hook={
|
||
|
|
"command": "python",
|
||
|
|
"args": ["-c", f"open('{marker}', 'w').write('ok')"],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = self._run_setup()
|
||
|
|
assert failed == [], failed
|
||
|
|
assert marker.exists()
|
||
|
|
assert marker.read_text() == "ok"
|
||
|
|
|
||
|
|
def test_skips_server_without_setup_hook(self):
|
||
|
|
"""Servers without a setup hook are skipped gracefully."""
|
||
|
|
self._create_server("no-setup")
|
||
|
|
|
||
|
|
failed = self._run_setup()
|
||
|
|
assert failed == [], failed
|
||
|
|
|
||
|
|
def test_forwards_env_vars(self):
|
||
|
|
"""WORLDBENCH_* env vars are forwarded to the setup hook."""
|
||
|
|
marker = Path(self.temp_dir) / "env_check.txt"
|
||
|
|
self._create_server(
|
||
|
|
"svc",
|
||
|
|
setup_hook={
|
||
|
|
"command": "python",
|
||
|
|
"args": [
|
||
|
|
"-c",
|
||
|
|
f"import os; open('{marker}', 'w').write(os.environ.get('WORLDBENCH_TASK_ID', 'MISSING'))",
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = self._run_setup({"WORLDBENCH_TASK_ID": "test-123"})
|
||
|
|
assert failed == [], failed
|
||
|
|
assert marker.read_text() == "test-123"
|
||
|
|
|
||
|
|
def test_respects_server_filter(self):
|
||
|
|
"""Only servers declaring the requested toolset are set up."""
|
||
|
|
marker_a = Path(self.temp_dir) / "a_ran.txt"
|
||
|
|
marker_b = Path(self.temp_dir) / "b_ran.txt"
|
||
|
|
self._create_server(
|
||
|
|
"alpha",
|
||
|
|
setup_hook={
|
||
|
|
"command": "python",
|
||
|
|
"args": ["-c", f"open('{marker_a}', 'w').write('ok')"],
|
||
|
|
},
|
||
|
|
toolsets={"read": ["tool_a"]},
|
||
|
|
)
|
||
|
|
self._create_server(
|
||
|
|
"beta",
|
||
|
|
setup_hook={
|
||
|
|
"command": "python",
|
||
|
|
"args": ["-c", f"open('{marker_b}', 'w').write('ok')"],
|
||
|
|
},
|
||
|
|
toolsets={"write": ["tool_b"]},
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = self._run_setup({"WORLDBENCH_TOOL_SETS": "alpha_read"})
|
||
|
|
assert failed == [], failed
|
||
|
|
assert marker_a.exists()
|
||
|
|
assert not marker_b.exists()
|
||
|
|
|
||
|
|
def test_fails_if_setup_hook_fails(self):
|
||
|
|
"""A non-zero exit from a setup hook is reported as a failed server."""
|
||
|
|
self._create_server(
|
||
|
|
"bad",
|
||
|
|
setup_hook={
|
||
|
|
"command": "python",
|
||
|
|
"args": ["-c", "import sys; sys.exit(1)"],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = self._run_setup()
|
||
|
|
assert failed == ["bad"], failed
|
||
|
|
|
||
|
|
def test_multi_step_setup(self):
|
||
|
|
"""Setup hooks can be a list of steps."""
|
||
|
|
marker1 = Path(self.temp_dir) / "step1.txt"
|
||
|
|
marker2 = Path(self.temp_dir) / "step2.txt"
|
||
|
|
self._create_server(
|
||
|
|
"svc",
|
||
|
|
setup_hook=[
|
||
|
|
{"command": "python", "args": ["-c", f"open('{marker1}', 'w').write('1')"]},
|
||
|
|
{"command": "python", "args": ["-c", f"open('{marker2}', 'w').write('2')"]},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = self._run_setup()
|
||
|
|
assert failed == [], failed
|
||
|
|
assert marker1.read_text() == "1"
|
||
|
|
assert marker2.read_text() == "2"
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|