CmdForge/tests/test_mcp.py

989 lines
37 KiB
Python

"""Tests for MCP client and McpStep execution."""
import sys
import subprocess
import time
from pathlib import Path
import pytest
import yaml
from unittest.mock import patch
from cmdforge.mcp_client import (
DEFAULT_INHERITED_ENV,
McpServerConfig,
McpClientManager,
_build_server_env,
_build_http_headers,
_normalize_result,
_sanitize,
_fingerprint,
load_mcp_config,
save_mcp_config,
)
from cmdforge.runner import _substitute_mcp_args, _deep_substitute, run_tool
from cmdforge.tool import McpStep, Tool
class TestAgentHostSetup:
def test_builds_current_vendor_cli_commands(self):
from cmdforge.agent_setup import build_host_add_command
codex = build_host_add_command(
"codex", "/usr/bin/codex", "cmdforge", "user"
)
assert codex[:5] == [
"/usr/bin/codex", "mcp", "add", "cmdforge", "--"
]
assert codex[-4:] == ["-m", "cmdforge.cli", "mcp", "serve"]
claude = build_host_add_command(
"claude-code", "/usr/bin/claude", "cmdforge", "project"
)
assert claude[:9] == [
"/usr/bin/claude", "mcp", "add", "--transport", "stdio",
"--scope", "project", "cmdforge", "--",
]
def test_codex_rejects_unsupported_scope(self):
from cmdforge.agent_setup import build_host_add_command
with pytest.raises(ValueError, match="user scope only"):
build_host_add_command(
"codex", "/usr/bin/codex", "cmdforge", "project"
)
def test_rejects_unsafe_registration_name(self):
from cmdforge.agent_setup import build_host_add_command
with pytest.raises(ValueError, match="registration name"):
build_host_add_command(
"codex", "/usr/bin/codex", "--config", "user"
)
def test_policy_merge_is_idempotent_and_preserves_user_text(self):
from cmdforge.agent_setup import POLICY_BODY, merge_agent_policy
original = "# Existing guidance\n\nKeep this.\n"
merged = merge_agent_policy(original)
assert merged.startswith(original)
assert POLICY_BODY.strip() in merged
assert merge_agent_policy(merged) == merged
def test_policy_merge_rejects_malformed_markers(self):
from cmdforge.agent_setup import POLICY_START, merge_agent_policy
with pytest.raises(ValueError, match="malformed"):
merge_agent_policy(f"text\n{POLICY_START}\n")
def test_policy_writer_refuses_symlink(self, tmp_path):
from cmdforge.agent_setup import write_agent_policy
target = tmp_path / "target.md"
target.write_text("unchanged")
link = tmp_path / "AGENTS.md"
link.symlink_to(target)
with pytest.raises(ValueError, match="symlinked"):
write_agent_policy(link, "replacement")
assert target.read_text() == "unchanged"
def test_dry_run_changes_nothing(self, tmp_path, capsys):
from cmdforge.agent_setup import configure_agent_host
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/codex"),
patch("cmdforge.agent_setup.subprocess.run") as run,
):
assert configure_agent_host(
"codex", project_dir=tmp_path, dry_run=True
) == 0
run.assert_not_called()
assert not (tmp_path / "AGENTS.md").exists()
output = capsys.readouterr().out
assert "Would run:" in output
assert "cmdforge-agent-policy:start" in output
def test_configures_host_and_managed_policy(self, tmp_path, capsys):
from cmdforge.agent_setup import configure_agent_host
results = [
subprocess.CompletedProcess([], 1, "", "not found"),
subprocess.CompletedProcess([], 0, "added", ""),
]
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/codex"),
patch("cmdforge.agent_setup.subprocess.run", side_effect=results) as run,
patch("cmdforge.agent_setup._warn_if_no_tools_exposed"),
):
assert configure_agent_host("codex", project_dir=tmp_path) == 0
assert run.call_count == 2
assert run.call_args_list[0].args[0] == [
"/usr/bin/codex", "mcp", "get", "cmdforge"
]
assert run.call_args_list[1].args[0][:5] == [
"/usr/bin/codex", "mcp", "add", "cmdforge", "--"
]
policy = (tmp_path / "AGENTS.md").read_text()
assert "cmdforge-agent-policy:start" in policy
assert "Do not install, publish, expose" in policy
assert "Configured CmdForge MCP" in capsys.readouterr().out
def test_existing_registration_is_not_overwritten(self, tmp_path):
from cmdforge.agent_setup import configure_agent_host
result = subprocess.CompletedProcess([], 0, "exists", "")
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/claude"),
patch("cmdforge.agent_setup.subprocess.run", return_value=result) as run,
patch("cmdforge.agent_setup._warn_if_no_tools_exposed"),
):
assert configure_agent_host(
"claude-code", project_dir=tmp_path
) == 0
assert run.call_count == 1
assert (tmp_path / "CLAUDE.md").exists()
def test_force_removes_then_readds_registration(self, tmp_path):
from cmdforge.agent_setup import configure_agent_host
results = [
subprocess.CompletedProcess([], 0, "exists", ""),
subprocess.CompletedProcess([], 0, "removed", ""),
subprocess.CompletedProcess([], 0, "added", ""),
]
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/claude"),
patch("cmdforge.agent_setup.subprocess.run", side_effect=results) as run,
patch("cmdforge.agent_setup._warn_if_no_tools_exposed"),
):
assert configure_agent_host(
"claude-code", project_dir=tmp_path,
install_policy=False, force=True,
) == 0
assert run.call_args_list[1].args[0] == [
"/usr/bin/claude", "mcp", "remove", "cmdforge"
]
assert run.call_args_list[2].args[0][:4] == [
"/usr/bin/claude", "mcp", "add", "--transport"
]
def test_failed_host_add_does_not_write_policy(self, tmp_path):
from cmdforge.agent_setup import configure_agent_host
results = [
subprocess.CompletedProcess([], 1, "", "missing"),
subprocess.CompletedProcess([], 1, "", "bad config"),
]
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/codex"),
patch("cmdforge.agent_setup.subprocess.run", side_effect=results),
):
assert configure_agent_host(
"codex", project_dir=tmp_path
) == 1
assert not (tmp_path / "AGENTS.md").exists()
def test_host_command_timeout_is_reported(self, tmp_path, capsys):
from cmdforge.agent_setup import configure_agent_host
with (
patch("cmdforge.agent_setup.shutil.which", return_value="/usr/bin/codex"),
patch(
"cmdforge.agent_setup.subprocess.run",
side_effect=subprocess.TimeoutExpired("codex", 30),
),
):
assert configure_agent_host(
"codex", project_dir=tmp_path, install_policy=False
) == 1
assert "timed out after 30 seconds" in capsys.readouterr().err
class TestMcpServerConfig:
def test_defaults(self):
cfg = McpServerConfig(name="test")
assert cfg.transport == "stdio"
assert cfg.timeout == 30
assert cfg.args == []
assert cfg.env == {}
assert cfg.approved is False
@pytest.mark.parametrize(
"change, message",
[
({"transport": "streamable-http"}, "requires a url"),
({"args": "-y"}, "list of strings"),
({"timeout": 0}, "greater than 0"),
({"timeout": True}, "must be a number"),
],
)
def test_validation_rejects_invalid_config(self, change, message):
command = None if change.get("transport") == "streamable-http" else "server"
cfg = McpServerConfig(name="test", command=command, **change)
with pytest.raises(ValueError, match=message):
cfg.validate()
def test_fingerprint_changes_with_command(self):
a = McpServerConfig(name="a", command="cmd-a")
b = McpServerConfig(name="b", command="cmd-b")
assert _fingerprint(a) != _fingerprint(b)
def test_fingerprint_same_for_same_config(self):
a = McpServerConfig(name="a", command="cmd", args=["-v"])
b = McpServerConfig(name="b", command="cmd", args=["-v"])
assert _fingerprint(a) == _fingerprint(b)
def test_streamable_http_accepts_https(self):
cfg = McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp", approved=True,
)
cfg.validate()
def test_streamable_http_allows_loopback_http_only(self):
McpServerConfig(
name="local", transport="streamable-http",
url="http://127.0.0.1:8000/mcp",
).validate()
with pytest.raises(ValueError, match="must use HTTPS"):
McpServerConfig(
name="remote", transport="streamable-http",
url="http://example.com/mcp",
).validate()
def test_streamable_http_rejects_url_credentials(self):
with pytest.raises(ValueError, match="must not contain credentials"):
McpServerConfig(
name="remote", transport="streamable-http",
url="https://user:pass@example.com/mcp",
).validate()
class TestSanitize:
def test_redacts_bearer(self):
assert "redacted" in _sanitize("Bearer abcdefghijklmnopqrstuvwxyz")
def test_redacts_sk_prefix(self):
assert "redacted" in _sanitize("sk-abcdefghijklmnopqrstuvwxyz")
def test_preserves_normal_text(self):
assert _sanitize("hello world") == "hello world"
def test_redacts_explicit_secret(self):
assert _sanitize("failed with hunter2", ["hunter2"]) == "failed with [redacted]"
class TestEnvironmentIsolation:
def test_only_allowlisted_environment_is_inherited(self, monkeypatch):
monkeypatch.setenv("CMDFORGE_TEST_SECRET", "do-not-leak")
cfg = McpServerConfig(name="test", command="server")
environment = _build_server_env(cfg)
assert "CMDFORGE_TEST_SECRET" not in environment
assert set(environment).issubset(set(DEFAULT_INHERITED_ENV))
def test_explicit_environment_reference_is_resolved(self, monkeypatch):
monkeypatch.setenv("CMDFORGE_TEST_SECRET", "allowed")
cfg = McpServerConfig(
name="test", command="server", env={"SERVER_TOKEN": "${CMDFORGE_TEST_SECRET}"}
)
assert _build_server_env(cfg)["SERVER_TOKEN"] == "allowed"
def test_http_header_reference_is_resolved(self, monkeypatch):
monkeypatch.setenv("MCP_TOKEN", "secret")
cfg = McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp",
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
)
assert _build_http_headers(cfg)["Authorization"] == "Bearer secret"
def test_missing_environment_reference_fails(self, monkeypatch):
monkeypatch.delenv("CMDFORGE_MISSING", raising=False)
cfg = McpServerConfig(
name="test", command="server", env={"SERVER_TOKEN": "${CMDFORGE_MISSING}"}
)
with pytest.raises(ValueError, match="is not set"):
_build_server_env(cfg)
class TestMcpConfigPersistence:
@pytest.fixture
def temp_mcp_file(self, tmp_path, monkeypatch):
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
yield config_file
def test_save_and_load(self, temp_mcp_file):
servers = [
McpServerConfig(
name="filesystem",
command="npx",
args=["-y", "@scope/server", "/tmp"],
timeout=30,
approved=True,
)
]
save_mcp_config(servers)
loaded = load_mcp_config()
assert len(loaded) == 1
assert loaded[0].name == "filesystem"
assert loaded[0].command == "npx"
assert loaded[0].args == ["-y", "@scope/server", "/tmp"]
assert loaded[0].approved is True
def test_load_no_file(self, temp_mcp_file):
assert load_mcp_config() == []
def test_streamable_http_round_trip(self, temp_mcp_file):
save_mcp_config([McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp",
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
approved=True,
)])
loaded = load_mcp_config()[0]
assert loaded.transport == "streamable-http"
assert loaded.command is None
assert loaded.url == "https://example.com/mcp"
assert loaded.headers == {"Authorization": "Bearer ${MCP_TOKEN}"}
def test_save_creates_0600_permissions(self, temp_mcp_file):
save_mcp_config([McpServerConfig(name="test", command="echo")])
perms = oct(temp_mcp_file.stat().st_mode & 0o777)
assert perms == "0o600"
def test_save_preserves_server_exposure_policy(self, temp_mcp_file):
temp_mcp_file.write_text(
"version: 1\nserver:\n expose: [summarize]\n deny: [dangerous]\n"
)
save_mcp_config([McpServerConfig(name="test", command="echo")])
data = yaml.safe_load(temp_mcp_file.read_text())
assert data["server"] == {
"expose": ["summarize"],
"deny": ["dangerous"],
}
class TestResultNormalization:
def test_auto_prefers_structured_content(self):
class FakeResult:
isError = False
structuredContent = {"sum": 3}
content = [type("Block", (), {"text": "plain text"})()]
result = _normalize_result(FakeResult(), "auto")
assert result == {"sum": 3}
def test_auto_falls_back_to_text(self):
class FakeResult:
isError = False
structuredContent = None
content = [type("Block", (), {"type": "text", "text": "hello"})()]
result = _normalize_result(FakeResult(), "auto")
assert result == "hello"
def test_auto_preserves_mixed_content(self):
class FakeResult:
isError = False
structuredContent = None
content = [
{"type": "text", "text": "hello"},
{"type": "image", "mimeType": "image/png", "data": "AA=="},
]
result = _normalize_result(FakeResult(), "auto")
assert result == FakeResult.content
def test_content_mode_always_returns_blocks(self):
class FakeResult:
isError = False
structuredContent = None
content = [{"type": "text", "text": "hello"}]
assert _normalize_result(FakeResult(), "content") == FakeResult.content
def test_structured_fails_without_structured_content(self):
class FakeResult:
isError = False
structuredContent = None
content = [type("Block", (), {"type": "text", "text": "hello"})()]
with pytest.raises(ValueError, match="structuredContent"):
_normalize_result(FakeResult(), "structured")
def test_text_mode(self):
class FakeResult:
isError = False
structuredContent = None
content = [
type("Block", (), {"type": "text", "text": "line1"})(),
type("Block", (), {"type": "text", "text": "line2"})(),
]
result = _normalize_result(FakeResult(), "text")
assert result == "line1\nline2"
def test_iserror_causes_failure(self):
class FakeResult:
isError = True
structuredContent = None
content = [type("Block", (), {"type": "text", "text": "error message"})()]
with pytest.raises(RuntimeError, match="error"):
_normalize_result(FakeResult(), "auto")
class TestMcpClientManager:
@pytest.fixture
def manager(self, tmp_path, monkeypatch):
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
return McpClientManager()
def test_list_servers_empty(self, manager):
assert manager.list_servers() == []
def test_list_servers_with_config(self, manager, tmp_path, monkeypatch):
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
save_mcp_config([McpServerConfig(name="test", command="echo")])
mgr = McpClientManager()
servers = mgr.list_servers()
assert len(servers) == 1
assert servers[0].name == "test"
def test_call_tool_unknown_server(self, manager):
with pytest.raises(KeyError, match="not configured"):
manager.call_tool("unknown", "tool", {})
def test_unapproved_server_is_not_executed(self, tmp_path, monkeypatch):
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
save_mcp_config([McpServerConfig(name="test", command="echo")])
with pytest.raises(PermissionError, match="not approved"):
McpClientManager().discover("test")
def test_missing_sdk_has_actionable_error(self, tmp_path, monkeypatch):
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
save_mcp_config([
McpServerConfig(name="test", command="echo", approved=True)
])
monkeypatch.setitem(sys.modules, "mcp", None)
with pytest.raises(ImportError, match=r"cmdforge\[mcp\]"):
McpClientManager().discover("test")
class TestArgumentSubstitution:
def test_single_variable_preserves_integer(self):
result = _deep_substitute("42", {"limit": 42})
assert result == "42" # no braces, so no substitution
def test_braced_variable_preserves_type(self):
variables = {"settings": {"limit": 50}}
result = _substitute_mcp_args(
{"options": {"limit": "{settings.limit}"}}, variables
)
assert result["options"]["limit"] == 50
def test_nested_dict_substitution(self):
variables = {"path": "/tmp/data.csv"}
result = _substitute_mcp_args(
{"files": [{"source": "{path}"}]}, variables
)
assert result["files"][0]["source"] == "/tmp/data.csv"
def test_string_with_variable_inside_text(self):
variables = {"name": "report"}
result = _deep_substitute("/tmp/{name}.csv", variables)
assert result == "/tmp/report.csv"
@pytest.mark.parametrize(
"value",
[
{"a": 1},
[1, 2],
-5,
"001",
"true",
False,
None,
],
)
def test_exact_reference_preserves_original_type(self, value):
assert _deep_substitute("{value}", {"value": value}) == value
class TestMcpStep:
def test_round_trip(self):
original = McpStep(
server="fixture", tool="add", arguments={"a": 1, "b": 2},
output_var="sum", result_mode="structured",
)
assert McpStep.from_dict(original.to_dict()) == original
@pytest.mark.parametrize(
"field, value",
[("server", ""), ("tool", None), ("arguments", []), ("result_mode", "raw")],
)
def test_rejects_invalid_fields(self, field, value):
data = {"server": "fixture", "tool": "echo", field: value}
with pytest.raises(ValueError):
McpStep.from_dict(data)
def test_runner_preserves_structured_result(self, monkeypatch):
calls = []
class FakeManager:
def call_tool(self, server, tool, arguments, result_mode="auto"):
calls.append((server, tool, arguments, result_mode))
return {"sum": 3}
monkeypatch.setattr("cmdforge.runner.McpClientManager", FakeManager)
tool = Tool(
name="mcp-add",
steps=[
McpStep(
server="fixture",
tool="add",
arguments={"a": "{input}", "b": 2},
output_var="result",
)
],
output="{result.sum}",
)
output, exit_code = run_tool(tool, 1, {})
assert (output, exit_code) == ("3", 0)
assert calls == [("fixture", "add", {"a": 1, "b": 2}, "auto")]
class TestRealMcpSdk:
@pytest.fixture
def manager(self, tmp_path, monkeypatch):
pytest.importorskip("mcp")
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
fixture = Path(__file__).parent / "fixtures" / "mcp_test_server.py"
save_mcp_config([
McpServerConfig(
name="fixture",
command=sys.executable,
args=[str(fixture)],
timeout=10,
approved=True,
)
])
return McpClientManager()
def test_discovery_uses_mcp_handshake_and_tools_list(self, manager):
tools = manager.discover("fixture")
schemas = {tool["name"]: tool for tool in tools}
assert {"echo", "add", "read_env", "current_directory", "wait"}.issubset(schemas)
assert schemas["add"]["inputSchema"]["required"] == ["a", "b"]
def test_calls_text_and_structured_tools(self, manager):
assert manager.call_tool("fixture", "echo", {"message": "hello"}, "text") == "hello"
assert manager.call_tool("fixture", "add", {"a": 1, "b": 2}) == {"sum": 3}
def test_parent_secret_is_not_leaked(self, manager, monkeypatch):
monkeypatch.setenv("CMDFORGE_TEST_SECRET", "do-not-leak")
result = manager.call_tool(
"fixture", "read_env", {"name": "CMDFORGE_TEST_SECRET"}, "text"
)
assert result == ""
def test_configured_working_directory_is_used(self, manager, tmp_path):
manager.list_servers()
manager._configs["fixture"].cwd = str(tmp_path)
assert manager.call_tool("fixture", "current_directory", {}, "text") == str(tmp_path)
def test_timeout_is_enforced(self, manager):
manager.list_servers()
manager._configs["fixture"].timeout = 0.5
started = time.monotonic()
with pytest.raises(TimeoutError, match="timed out"):
manager.call_tool("fixture", "wait", {"seconds": 10}, "text")
assert time.monotonic() - started < 4
class TestMcpCli:
def test_add_accepts_leading_dash_argument(self, tmp_path, monkeypatch, capsys):
from cmdforge.cli import main
config_file = tmp_path / "mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", config_file)
monkeypatch.setattr(
sys,
"argv",
[
"cmdforge", "mcp", "add", "fixture", "--command", "npx",
"--arg=-y", "--arg", "@scope/server",
],
)
assert main() == 0
loaded = load_mcp_config()
assert loaded[0].args == ["-y", "@scope/server"]
assert loaded[0].approved is True
assert "saved and approved" in capsys.readouterr().out
def test_bare_mcp_lists_servers(self, tmp_path, monkeypatch, capsys):
from cmdforge.cli import main
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", tmp_path / "mcp.yaml")
monkeypatch.setattr(sys, "argv", ["cmdforge", "mcp"])
assert main() == 0
assert "No MCP servers configured" in capsys.readouterr().out
class TestMcpServeConfig:
def test_empty_expose_exposes_nothing(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=[], deny=[])
assert not config.is_exposed("summarize")
def test_wildcard_exposes_all_public(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=["*"], deny=[])
assert config.is_exposed("summarize")
assert config.is_exposed("any-tool")
def test_deny_overrides_expose(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=["*"], deny=["dangerous"])
assert config.is_exposed("summarize")
assert not config.is_exposed("dangerous")
def test_prefix_deny(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=["*"], deny=["internal-*"])
assert not config.is_exposed("internal-cleanup")
assert not config.is_exposed("internal-")
assert config.is_exposed("public-tool")
def test_explicit_expose(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=["summarize", "extract"])
assert config.is_exposed("summarize")
assert config.is_exposed("extract")
assert not config.is_exposed("other")
@pytest.mark.parametrize(
"field, value",
[("expose", "*"), ("deny", "dangerous"), ("expose", [""])],
)
def test_rejects_malformed_policy_lists(self, field, value):
from cmdforge.mcp_client import McpServeConfig
values = {"expose": [], "deny": [], field: value}
with pytest.raises(ValueError, match="list of non-empty strings"):
McpServeConfig(**values)
def test_general_wildcard_matching(self):
from cmdforge.mcp_client import McpServeConfig
config = McpServeConfig(expose=["official/*"], deny=["*/dangerous"])
assert config.is_exposed("official/summarize")
assert not config.is_exposed("official/dangerous")
def test_private_tool_still_honors_deny(self):
from cmdforge.mcp_client import McpServeConfig
from cmdforge.mcp_server import _is_exposable
from cmdforge.tool import Tool
tool = Tool(name="dangerous", visibility="private")
config = McpServeConfig(expose=["official/dangerous"], deny=["official/*"])
assert not _is_exposable(tool, config, qualified_name="official/dangerous")
def test_private_qualified_tool_requires_qualified_expose(self):
from cmdforge.mcp_client import McpServeConfig
from cmdforge.mcp_server import _is_exposable
from cmdforge.tool import Tool
tool = Tool(name="extract", visibility="private")
assert not _is_exposable(
tool,
McpServeConfig(expose=["extract"]),
qualified_name="official/extract",
)
assert _is_exposable(
tool,
McpServeConfig(expose=["official/extract"]),
qualified_name="official/extract",
)
class TestToolNameMapping:
def test_simple_name_passes_through(self):
from cmdforge.mcp_server import _cmdforge_tool_name
assert _cmdforge_tool_name("summarize") == "summarize"
def test_qualified_name_uses_double_underscore(self):
from cmdforge.mcp_server import _cmdforge_tool_name
assert _cmdforge_tool_name("owner/tool") == "owner__tool"
class TestArgumentToJsonSchema:
def test_string_arg(self):
from cmdforge.mcp_server import _argument_to_json_schema
from cmdforge.tool import ToolArgument
arg = ToolArgument(flag="--name", variable="name", description="The name")
schema = _argument_to_json_schema(arg)
assert schema["type"] == "string"
assert schema["description"] == "The name"
def test_integer_arg_with_default(self):
from cmdforge.tool import ToolArgument
from cmdforge.mcp_server import _argument_to_json_schema
arg = ToolArgument(
flag="--limit", variable="limit", default="10", type="integer"
)
schema = _argument_to_json_schema(arg)
assert schema["type"] == "integer"
assert schema["default"] == 10
def test_arg_with_enum(self):
from cmdforge.tool import ToolArgument
from cmdforge.mcp_server import _argument_to_json_schema
arg = ToolArgument(
flag="--mode", variable="mode", enum=["fast", "accurate"]
)
schema = _argument_to_json_schema(arg)
assert schema["enum"] == ["fast", "accurate"]
class TestBuildToolSchema:
def test_builds_input_schema(self, tmp_path):
from cmdforge.tool import Tool, ToolArgument
from cmdforge.mcp_server import _build_tool_schema
with patch('cmdforge.tool.TOOLS_DIR', tmp_path / ".cmdforge"):
tool = Tool(
name="greet",
description="Greet someone",
arguments=[
ToolArgument(flag="--name", variable="name",
description="Who to greet", default="World"),
ToolArgument(flag="--count", variable="count", default="1"),
],
output="Hello {name} x{count}\n",
)
schema = _build_tool_schema(tool)
assert schema["name"] == "greet"
assert "Greet" in schema["description"]
assert schema["inputSchema"]["type"] == "object"
assert "name" in schema["inputSchema"]["properties"]
assert "count" in schema["inputSchema"]["properties"]
assert "input" in schema["inputSchema"]["properties"]
class TestRegisteredMcpTool:
def test_fastmcp_uses_flat_typed_schema_and_invokes_tool(self):
import asyncio
from mcp.server.fastmcp import FastMCP
from cmdforge.mcp_server import _build_tool_schema, _register_tool
from cmdforge.tool import Tool, ToolArgument
server = FastMCP("test")
tool = Tool(
name="greet",
arguments=[
ToolArgument(
flag="--name",
variable="name",
type="string",
required=True,
description="Who to greet",
),
ToolArgument(
flag="--count",
variable="count",
type="integer",
default=1,
enum=[1, 2, 3],
),
],
output="Hello {name} x{count}: {input}",
)
_register_tool(server, "greet", "greet", _build_tool_schema(tool), tool)
async def exercise():
listed = (await server.list_tools())[0]
result = await server.call_tool(
"greet", {"name": "Alice", "count": 2, "input": "welcome"}
)
return listed, result
listed, result = asyncio.run(exercise())
assert listed.inputSchema["required"] == ["name"]
assert listed.inputSchema["properties"]["count"]["type"] == "integer"
assert listed.inputSchema["properties"]["count"]["enum"] == [1, 2, 3]
assert "kwargs" not in listed.inputSchema["properties"]
assert result[0].text == "Hello Alice x2: welcome"
def test_handler_starts_at_inherited_depth(self):
import asyncio
from mcp.server.fastmcp import FastMCP
from cmdforge.mcp_server import _build_tool_schema, _register_tool
from cmdforge.tool import Tool
server = FastMCP("test")
tool = Tool(name="depth", output="ok")
with patch("cmdforge.runner.run_tool", return_value=("ok", 0)) as run:
_register_tool(server, "depth", "depth", _build_tool_schema(tool), tool)
asyncio.run(server.call_tool("depth", {}))
assert run.call_args.kwargs["_depth"] == 0
def test_handler_rejects_exhausted_mcp_depth(self, monkeypatch):
import asyncio
from mcp.server.fastmcp import FastMCP
from cmdforge.mcp_server import (
MAX_TOOL_DEPTH,
_build_tool_schema,
_register_tool,
)
from cmdforge.tool import Tool
monkeypatch.setenv("CMDFORGE_MCP_DEPTH", str(MAX_TOOL_DEPTH))
server = FastMCP("test")
tool = Tool(name="depth", output="ok")
_register_tool(server, "depth", "depth", _build_tool_schema(tool), tool)
with pytest.raises(Exception, match="Maximum MCP nesting depth"):
asyncio.run(server.call_tool("depth", {}))
def test_serve_fails_closed_on_mapped_name_collision(self):
from cmdforge.mcp_client import McpServeConfig
from cmdforge.mcp_server import serve
from cmdforge.tool import Tool
tools = {
"owner/tool": Tool(name="tool"),
"owner__tool": Tool(name="owner__tool"),
}
with (
patch(
"cmdforge.mcp_server.load_mcp_serve_config",
return_value=McpServeConfig(expose=["*"]),
),
patch("cmdforge.tool.list_tools", return_value=list(tools)),
patch("cmdforge.tool.load_tool", side_effect=tools.get),
patch("mcp.server.fastmcp.FastMCP.run"),
):
with pytest.raises(RuntimeError, match="name collision"):
serve()
class TestStreamableHttpServerSafety:
def test_defaults_are_loopback_with_origin_allowlist(self):
from cmdforge.mcp_server import _validate_http_server_options
origins, token = _validate_http_server_options(
"127.0.0.1", 8000, [], None, None
)
assert token is None
assert origins == [
"http://localhost:8000", "http://127.0.0.1:8000"
]
def test_nonlocal_requires_https_and_auth(self):
from cmdforge.mcp_server import _validate_http_server_options
with pytest.raises(ValueError, match="external-url"):
_validate_http_server_options("0.0.0.0", 8000, [], "secret", None)
with pytest.raises(ValueError, match="auth-token"):
_validate_http_server_options(
"0.0.0.0", 8000, [], None, "https://mcp.example.com"
)
def test_nonlocal_infers_https_origin_and_expands_token(self, monkeypatch):
from cmdforge.mcp_server import _validate_http_server_options
monkeypatch.setenv("MCP_AUTH", "secret-value")
origins, token = _validate_http_server_options(
"0.0.0.0", 8000, [], "${MCP_AUTH}",
"https://mcp.example.com",
)
assert origins == ["https://mcp.example.com"]
assert token == "secret-value"
def test_nonlocal_rejects_insecure_origin(self):
from cmdforge.mcp_server import _validate_http_server_options
with pytest.raises(ValueError, match="origins must use HTTPS"):
_validate_http_server_options(
"0.0.0.0", 8000, ["http://example.com"], "secret",
"https://mcp.example.com",
)
class TestCmdForgeMcpServerEndToEnd:
def test_stdio_discovery_and_invocation(self, tmp_path, monkeypatch):
pytest.importorskip("mcp")
home = tmp_path / "home"
cmdforge_dir = home / ".cmdforge"
cmdforge_dir.mkdir(parents=True)
(cmdforge_dir / "mcp.yaml").write_text(
yaml.safe_dump({
"version": 1,
"server": {
"expose": ["greet", "echo-input", "hidden"],
"deny": ["hidden"],
},
})
)
tools = {
"greet": {
"name": "greet",
"arguments": [{
"flag": "--name",
"variable": "name",
"type": "string",
"required": True,
}],
"steps": [],
"output": "Hello {name}",
},
"echo-input": {
"name": "echo-input",
"steps": [],
"output": "{input}",
},
"hidden": {
"name": "hidden",
"visibility": "private",
"steps": [],
"output": "secret",
},
}
for name, data in tools.items():
tool_dir = cmdforge_dir / name
tool_dir.mkdir()
(tool_dir / "config.yaml").write_text(yaml.safe_dump(data))
client_config = tmp_path / "client-mcp.yaml"
monkeypatch.setattr("cmdforge.mcp_client.MCP_CONFIG_FILE", client_config)
save_mcp_config([
McpServerConfig(
name="cmdforge",
command=sys.executable,
args=["-m", "cmdforge.cli", "mcp", "serve"],
env={"HOME": str(home)},
timeout=10,
approved=True,
)
])
manager = McpClientManager()
discovered = {tool["name"]: tool for tool in manager.discover("cmdforge")}
assert set(discovered) == {"greet", "echo-input"}
assert discovered["greet"]["inputSchema"]["required"] == ["name"]
assert manager.call_tool(
"cmdforge", "greet", {"name": "Alice"}, "text"
) == "Hello Alice"
assert manager.call_tool(
"cmdforge", "echo-input", {"input": "through stdin"}, "text"
) == "through stdin"