CmdForge/tests/test_mcp.py

810 lines
30 KiB
Python

"""Tests for MCP client and McpStep execution."""
import sys
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 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"