"""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, m2pack_build, m2pack_diff, m2pack_export_runtime_key, m2pack_verify, 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", "m2pack_build", "m2pack_verify", "m2pack_diff", "m2pack_export_runtime_key", } def test_tool_catalogue_is_exactly_the_phase_one_plus_four_set(): assert {t.name for t in TOOL_SPECS} == EXPECTED_TOOL_NAMES assert len(TOOL_SPECS) == 12 def test_every_tool_has_known_group_and_description(): for spec in TOOL_SPECS: assert spec.subcommand[0] in {"release", "m2pack"} 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, "m2pack_build": m2pack_build, "m2pack_verify": m2pack_verify, "m2pack_diff": m2pack_diff, "m2pack_export_runtime_key": m2pack_export_runtime_key, } @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 "launcher_publish" 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})