Files
metin-release-cli/tests/mcp/test_tool_schema.py
2026-04-14 19:33:41 +02:00

141 lines
4.2 KiB
Python

"""Schema tests — each MCP tool must mirror its CLI subcommand exactly."""
from __future__ import annotations
import argparse
import pytest
from metin_release.commands import (
build_manifest,
diff_remote,
inspect,
promote,
publish,
sign,
upload_blobs,
verify_public,
)
from metin_release_mcp.errors import UnknownToolError
from metin_release_mcp.server import dispatch
from metin_release_mcp.tool_defs import (
TOOL_SPECS,
TOOLS_BY_NAME,
build_cli_args,
json_schema,
)
EXPECTED_TOOL_NAMES = {
"release_inspect",
"release_build_manifest",
"release_sign",
"release_diff_remote",
"release_upload_blobs",
"release_promote",
"release_verify_public",
"release_publish",
}
def test_tool_catalogue_is_exactly_the_phase_one_set():
assert {t.name for t in TOOL_SPECS} == EXPECTED_TOOL_NAMES
assert len(TOOL_SPECS) == 8
def test_every_tool_has_release_subcommand_and_description():
for spec in TOOL_SPECS:
assert spec.subcommand[0] == "release"
assert spec.description.strip()
schema = json_schema(spec)
assert schema["type"] == "object"
assert schema["additionalProperties"] is False
def _argparse_flags_for(add_parser_fn) -> dict[str, dict]:
"""Introspect a command's argparse signature.
Returns a mapping from flag name (e.g. ``--source``) to a dict with
``required`` and ``kind`` ("boolean" for store_true, else "value").
"""
parser = argparse.ArgumentParser()
sub = parser.add_subparsers()
sp = add_parser_fn(sub)
out: dict[str, dict] = {}
for action in sp._actions:
if not action.option_strings:
continue
if action.dest in ("help",):
continue
flag = action.option_strings[0]
is_bool = isinstance(action, argparse._StoreTrueAction)
out[flag] = {"required": action.required, "kind": "boolean" if is_bool else "value"}
return out
_COMMAND_MODULES = {
"release_inspect": inspect,
"release_build_manifest": build_manifest,
"release_sign": sign,
"release_diff_remote": diff_remote,
"release_upload_blobs": upload_blobs,
"release_promote": promote,
"release_verify_public": verify_public,
"release_publish": publish,
}
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOL_NAMES))
def test_schema_mirrors_argparse(tool_name):
spec = TOOLS_BY_NAME[tool_name]
mod = _COMMAND_MODULES[tool_name]
argparse_flags = _argparse_flags_for(mod.add_parser)
spec_flags = {f.cli_flag: f for f in spec.fields}
assert set(spec_flags) == set(argparse_flags), (
f"{tool_name}: spec flags {set(spec_flags)} != argparse flags {set(argparse_flags)}"
)
for flag, info in argparse_flags.items():
field = spec_flags[flag]
assert field.required == info["required"], f"{tool_name} {flag} required mismatch"
if info["kind"] == "boolean":
assert field.kind == "boolean", f"{tool_name} {flag} expected boolean"
else:
assert field.kind != "boolean", f"{tool_name} {flag} should not be boolean"
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOL_NAMES))
def test_required_fields_in_json_schema(tool_name):
spec = TOOLS_BY_NAME[tool_name]
schema = json_schema(spec)
required_in_spec = {f.name for f in spec.fields if f.required}
required_in_schema = set(schema.get("required", []))
assert required_in_schema == required_in_spec
def test_unknown_tool_rejected_by_dispatch():
with pytest.raises(UnknownToolError):
dispatch("nope_not_a_tool", {})
def test_unknown_tool_name_not_in_catalogue():
assert "release_rollback" not in TOOLS_BY_NAME
assert "erp_reserve" not in TOOLS_BY_NAME
assert "m2pack_build" not in TOOLS_BY_NAME
def test_build_cli_args_rejects_missing_required():
from metin_release_mcp.errors import InvalidToolInputError
spec = TOOLS_BY_NAME["release_inspect"]
with pytest.raises(InvalidToolInputError):
build_cli_args(spec, {})
def test_build_cli_args_rejects_unknown_fields():
from metin_release_mcp.errors import InvalidToolInputError
spec = TOOLS_BY_NAME["release_inspect"]
with pytest.raises(InvalidToolInputError):
build_cli_args(spec, {"source": "/x", "nonsense": 1})