141 lines
4.2 KiB
Python
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})
|