15 Commits
v0.1.0 ... main

Author SHA1 Message Date
Jan Nedbal
5c32224ff0 Merge branch 'claude/phase-4-m2pack'
Add Phase 4 m2pack CLI + MCP wrapping. New top-level 'metin-release
m2pack' command group with build, verify, diff, and export-runtime-key
subcommands that shell out to the real m2pack-secure binary (resolved
via M2PACK_BINARY env or PATH). Each wrapper translates m2pack's own
JSON envelope into the canonical Result shape, keeping the raw m2pack
output under data.m2pack for callers that need the untranslated version.
The MCP server exposes the same four as m2pack_* tools via the existing
ToolSpec catalogue; all 12 tools (8 release_* + 4 m2pack_*) appear in
--list-tools and pass the parametrised schema mirror test.

93 tests green (71 -> 93), no new dependencies.
2026-04-14 22:33:13 +02:00
Jan Nedbal
22f67a0589 docs: document m2pack subcommands
Add a Phase 4 'm2pack commands' section to docs/cli.md with each
subcommand's flags and a pointer at the m2pack-secure repo for
installation. Update README.md with a short m2pack paragraph and
append the Phase 4 entry to the Unreleased CHANGELOG section.
2026-04-14 22:31:16 +02:00
Jan Nedbal
1201ec50d2 tests: cover m2pack cli and mcp additions
Add discovery-helper tests (env var override, PATH fallback, missing
binary) and command tests that point M2PACK_BINARY at a Python stub
script echoing canned JSON per subcommand. Cover success paths,
non-zero exit, non-JSON output, JSON-array output, and missing binary.
Extend the MCP schema mirror test to cover all 12 tools and add
dispatch tests for the new m2pack_* argv translation.
2026-04-14 22:31:11 +02:00
Jan Nedbal
c4c65e2fe7 mcp: expose m2pack_* tools in the mcp server
Add four ToolSpec entries (m2pack_build, m2pack_verify, m2pack_diff,
m2pack_export_runtime_key) mirroring the CLI argparse signatures.
server.py auto-enumerates TOOL_SPECS so no wiring change needed.
2026-04-14 22:31:05 +02:00
Jan Nedbal
a289cd7c25 cli: add m2pack command group to dispatcher
Register the m2pack subparser and wire build/verify/diff/export-runtime-key
into _COMMAND_MAP alongside the existing release group.
2026-04-14 22:31:00 +02:00
Jan Nedbal
197c5ba8a2 cli: implement m2pack diff and export-runtime-key wrappers
diff promotes m2pack's added/removed/changed/unchanged counts into
data.stats when m2pack reports them as lists or ints. export-runtime-key
follows the real binary's --key/--public-key/--output/--key-id/--format
surface (not the original plan's --pack/--master-key).
2026-04-14 22:30:56 +02:00
Jan Nedbal
ae0cbb7e9b cli: implement m2pack build and verify wrappers
Shell out to m2pack-secure with --json, parse its envelope, and
translate into the standard metin-release Result envelope under
data.m2pack. Non-zero exit and non-JSON output map to SubprocessError
with m2pack_failed / m2pack_invalid_json / m2pack_empty_output codes.
2026-04-14 22:30:51 +02:00
Jan Nedbal
70d20f0f18 cli: add m2pack binary discovery helper
Resolve the m2pack-secure binary via M2PACK_BINARY env var or PATH,
raising ValidationError(m2pack_not_found) when neither works.
2026-04-14 22:30:45 +02:00
Jan Nedbal
50ea80d64b Merge branch 'claude/phase-3-mcp'
Add Phase 3 metin-release-mcp — a thin MCP stdio server wrapping the
Phase 1 CLI as 8 release_* tools. Zero business logic: every tool
spawns metin-release --json, parses stdout, returns the envelope
verbatim. Tool schemas mirror the argparse signatures and are covered
by a parametrised test so drift fails loudly. Keeps existing Phase 1
code untouched.
2026-04-14 19:36:18 +02:00
Jan Nedbal
b2283b0c3f docs: document mcp server usage 2026-04-14 19:33:48 +02:00
Jan Nedbal
a8ae85c0a4 tests: add mcp wrapper test suite 2026-04-14 19:33:41 +02:00
Jan Nedbal
79df538226 mcp: add server stdio entry and --help 2026-04-14 19:33:33 +02:00
Jan Nedbal
7117d25a9a mcp: register release_* tools mapping 1:1 to cli subcommands 2026-04-14 19:33:25 +02:00
Jan Nedbal
860face1d1 mcp: add cli runner that spawns metin-release and parses json 2026-04-14 19:33:18 +02:00
Jan Nedbal
d55291e75e mcp: scaffold metin_release_mcp package and pyproject entry 2026-04-14 19:33:00 +02:00
26 changed files with 2139 additions and 1 deletions

View File

@@ -7,6 +7,30 @@ follow semantic versioning: major for incompatible CLI or JSON contract changes,
minor for new subcommands or additive flags, patch for fixes that preserve the
contract.
## [Unreleased]
### Added
- `metin-release m2pack <build|verify|diff|export-runtime-key>` subcommands that wrap the m2pack-secure binary.
- `m2pack_build`, `m2pack_verify`, `m2pack_diff`, `m2pack_export_runtime_key` MCP tools mirroring the CLI surface.
- `src/metin_release/m2pack_binary.py` binary-discovery helper using `M2PACK_BINARY` env var or `PATH`.
- `metin_release_mcp` package and `metin-release-mcp` console script — a
thin Model Context Protocol stdio server that wraps the Phase 1
`release …` subcommands as eight MCP tools (`release_inspect`,
`release_build_manifest`, `release_sign`, `release_diff_remote`,
`release_upload_blobs`, `release_promote`, `release_verify_public`,
`release_publish`). Each tool's input schema mirrors the CLI's
argparse signature 1:1; the wrapper shells out to
`metin-release <subcommand> --json …` and returns the parsed envelope
verbatim with zero duplicated business logic.
- `[mcp]` optional dependency group pulling in the official `mcp`
Python SDK.
- `docs/mcp.md` — MCP server usage guide.
- `tests/mcp/` — 45 new tests covering the CLI runner (success path,
error-envelope passthrough, unparseable output, missing binary),
tool schemas (mirror checks against each command's argparse), and
dispatch translation (booleans, optionals, paths with spaces).
## [0.1.0] - 2026-04-14
First Phase 1 drop. Implements the minimum asset release path the

View File

@@ -31,3 +31,29 @@ Add `--json` to get a machine-parseable envelope on stdout. Exit codes:
| 4 | reserved (ERP sync, Phase 2+) |
See `docs/cli.md` for the full command reference.
## m2pack commands
Phase 4 adds a `metin-release m2pack …` command group that wraps the
[`m2pack-secure`](https://gitea.jakubkadlec.dev/metin-server/m2pack-secure)
binary for building, verifying, diffing, and runtime-key-exporting
signed `.m2p` archives. The binary is not bundled — build it from the
m2pack-secure repo and either put it on `PATH` or set `M2PACK_BINARY`
to an absolute path.
```
metin-release m2pack build --input ... --output a.m2p --key ck --sign-secret-key sk
metin-release m2pack verify --archive a.m2p --public-key pk
```
## MCP server
The `metin-release-mcp` console script (Phase 3) exposes each Phase 1
subcommand as an MCP tool over stdio. Install with the `mcp` extra:
```
pip install -e .[mcp]
metin-release-mcp --help
```
See `docs/mcp.md` for tool list, client wiring, and error handling.

View File

@@ -1,6 +1,7 @@
# metin-release — CLI reference
Phase 1 commands. All subcommands share the top-level flags.
Phase 1 `release …` commands and Phase 4 `m2pack …` commands. All
subcommands share the top-level flags.
## Top-level flags
@@ -145,3 +146,75 @@ metin-release release publish \
[--created-at ...] [--sample-blobs N] \
[--yes] [--force] [--dry-run-upload]
```
## m2pack commands
Phase 4 subcommands wrap the `m2pack-secure` binary and translate its
JSON envelopes into the standard metin-release result envelope. The
binary is **not** shipped with this CLI — build it from
[`metin-server/m2pack-secure`](https://gitea.jakubkadlec.dev/metin-server/m2pack-secure)
and either put it on `PATH` or point at it via the `M2PACK_BINARY`
environment variable.
All m2pack commands pass through `--json` to the real tool, so the
raw m2pack envelope is always available under `data.m2pack`. When
m2pack exits non-zero or emits non-JSON output the wrapper raises a
subprocess error with `m2pack_failed` / `m2pack_invalid_json` /
`m2pack_empty_output` error codes.
### `m2pack build`
Build a signed `.m2p` archive from a client asset directory.
```
metin-release m2pack build \
--input /path/to/client-assets \
--output /path/to/out.m2p \
--key /path/to/content.key \
--sign-secret-key /path/to/signing.sk \
[--key-id N]
```
### `m2pack verify`
Verify an `.m2p` archive's signature (and optionally full-decrypt it).
```
metin-release m2pack verify \
--archive /path/to/a.m2p \
[--public-key /path/to/signing.pub] \
[--key /path/to/content.key]
```
Passing `--key` enables full-decrypt verification; omitting it only
checks manifest structure and signature.
### `m2pack diff`
Diff two directories and/or `.m2p` archives. Either side can be a
directory or an archive; m2pack figures it out.
```
metin-release m2pack diff --left /old --right /new.m2p
```
The wrapper promotes m2pack's added/removed/changed/unchanged counts
into `data.stats` when available.
### `m2pack export-runtime-key`
Export a launcher runtime-key payload (json or raw blob) from a master
content key + signing public key. Used to seed the launcher's bundled
runtime-key file during release workflows.
```
metin-release m2pack export-runtime-key \
--key /path/to/content.key \
--public-key /path/to/signing.pub \
--output /path/to/runtime-key.json \
[--key-id N] [--format json|blob]
```
See `docs/key-rotation.md` in `m2pack-secure` for when to re-export
runtime keys.

120
docs/mcp.md Normal file
View File

@@ -0,0 +1,120 @@
# metin-release-mcp
Thin [Model Context Protocol](https://modelcontextprotocol.io/) server that
wraps the Phase 1 `metin-release` CLI. Each `release …` subcommand is exposed
as an MCP tool. The server contains no release business logic: it shells out
to the real CLI with `--json` and returns the parsed envelope verbatim.
## Install
The server ships as an optional extra alongside the main CLI:
```
pip install -e '.[mcp]'
```
This installs the `mcp` Python SDK and adds a `metin-release-mcp` console
script plus a `python -m metin_release_mcp` module entry.
## Running
The server speaks MCP over stdio, so you wire it into an MCP-capable client
(Claude Desktop, Claude Code, etc.) as a stdio command. Example client entry:
```json
{
"mcpServers": {
"metin-release": {
"command": "metin-release-mcp",
"env": {
"METIN_RELEASE_BINARY": "/usr/local/bin/metin-release"
}
}
}
}
```
You can also poke at it directly:
- `metin-release-mcp --help` — list registered tools
- `metin-release-mcp --list-tools` — dump the full tool JSON schemas
- `metin-release-mcp --version`
## Tools
| Tool | CLI subcommand |
|---|---|
| `release_inspect` | `metin-release release inspect` |
| `release_build_manifest` | `metin-release release build-manifest` |
| `release_sign` | `metin-release release sign` |
| `release_diff_remote` | `metin-release release diff-remote` |
| `release_upload_blobs` | `metin-release release upload-blobs` |
| `release_promote` | `metin-release release promote` |
| `release_verify_public` | `metin-release release verify-public` |
| `release_publish` | `metin-release release publish` |
Tool input keys match CLI flag names with `_` instead of `-`
(`--base-url``base_url`, `--dry-run``dry_run`). Boolean fields
correspond to argparse `store_true` flags: pass `true` to set the flag,
omit or pass `false` to leave it off.
### Example invocation
```json
{
"name": "release_inspect",
"arguments": {
"source": "/srv/metin/client"
}
}
```
The server runs `metin-release release inspect --json --source /srv/metin/client`
and returns the full JSON envelope:
```json
{
"ok": true,
"command": "release inspect",
"status": "inspected",
"stats": {
"source_path": "/srv/metin/client",
"file_count": 9166,
"total_bytes": 3523473920,
"launcher_present": true,
"main_exe_present": true
}
}
```
## CLI resolution
On every tool call the server resolves the `metin-release` binary in this order:
1. `METIN_RELEASE_BINARY` environment variable, if set and non-empty
2. `shutil.which("metin-release")` against `PATH`
If neither resolves, the tool call returns a wrapper-level error envelope
(`error.code = "cli_not_found"`).
## Error handling
The server never invents its own release-level errors. There are three paths:
- **Success** — CLI exits 0 with a valid JSON envelope → envelope returned as-is
- **CLI-level failure** — CLI exits non-zero with an `{"ok": false, "error": …}`
envelope → that envelope is returned as-is, plus the CLI's stderr is
attached as a diagnostic text block
- **Wrapper failure** — binary missing, unparseable stdout, unknown tool,
invalid input → synthetic envelope with one of
`cli_not_found`, `cli_unparseable_output`, `unknown_tool`,
`invalid_tool_input`
## Environment variables
| Variable | Purpose |
|---|---|
| `METIN_RELEASE_BINARY` | Override the `metin-release` binary path. |
Any env vars the wrapped CLI honours (`METIN_RELEASE_MAKE_MANIFEST`,
`METIN_RELEASE_SIGN_MANIFEST`) are inherited by the subprocess unchanged.

View File

@@ -22,9 +22,13 @@ dev = [
"pytest>=8",
"pytest-mock>=3",
]
mcp = [
"mcp>=1.0",
]
[project.scripts]
metin-release = "metin_release.cli:main"
metin-release-mcp = "metin_release_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -11,6 +11,10 @@ from .commands import (
build_manifest,
diff_remote,
inspect,
m2pack_build,
m2pack_diff,
m2pack_export_runtime_key,
m2pack_verify,
promote,
publish,
sign,
@@ -68,6 +72,18 @@ def _build_parser() -> argparse.ArgumentParser:
sp = mod.add_parser(rsub)
_add_common_flags(sp)
m2pack = sub.add_parser("m2pack", help="m2pack-secure archive commands.")
msub = m2pack.add_subparsers(dest="cmd", metavar="<command>")
msub.required = True
for mod in (
m2pack_build,
m2pack_verify,
m2pack_diff,
m2pack_export_runtime_key,
):
sp = mod.add_parser(msub)
_add_common_flags(sp)
return parser
@@ -80,6 +96,13 @@ _COMMAND_MAP: dict[tuple[str, str], tuple[str, CommandFn]] = {
("release", "promote"): ("release promote", promote.run),
("release", "verify-public"): ("release verify-public", verify_public.run),
("release", "publish"): ("release publish", publish.run),
("m2pack", "build"): ("m2pack build", m2pack_build.run),
("m2pack", "verify"): ("m2pack verify", m2pack_verify.run),
("m2pack", "diff"): ("m2pack diff", m2pack_diff.run),
("m2pack", "export-runtime-key"): (
"m2pack export-runtime-key",
m2pack_export_runtime_key.run,
),
}

View File

@@ -4,6 +4,10 @@ from . import (
build_manifest,
diff_remote,
inspect,
m2pack_build,
m2pack_diff,
m2pack_export_runtime_key,
m2pack_verify,
promote,
publish,
sign,
@@ -15,6 +19,10 @@ __all__ = [
"build_manifest",
"diff_remote",
"inspect",
"m2pack_build",
"m2pack_diff",
"m2pack_export_runtime_key",
"m2pack_verify",
"promote",
"publish",
"sign",

View File

@@ -0,0 +1,78 @@
"""Shared helper to invoke the real m2pack CLI and translate its JSON.
m2pack-secure emits its own JSON envelopes with ``--json``. Their shape is
not identical to our :class:`~metin_release.result.Result` envelope, so we
wrap the raw dict under ``data["m2pack"]`` and promote a few well-known
fields (artifact paths, counts) where it makes sense per command.
If m2pack exits non-zero or prints non-JSON, we raise
:class:`~metin_release.errors.SubprocessError` so the CLI exits with code
1 and a readable error envelope.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
from ..errors import SubprocessError
from ..log import get_logger
from ..m2pack_binary import resolve_m2pack_binary
def run_m2pack(subcommand: str, argv: list[str]) -> dict[str, Any]:
"""Invoke ``m2pack <subcommand> [argv...] --json`` and return parsed JSON.
Raises :class:`SubprocessError` (exit code 1) on any failure: binary
missing, non-zero exit, empty stdout, or non-JSON stdout. Missing
binary is handled inside :func:`resolve_m2pack_binary` by raising a
:class:`~metin_release.errors.ValidationError` which the dispatcher
already converts into the standard error envelope.
"""
log = get_logger()
binary: Path = resolve_m2pack_binary()
cmd = [str(binary), subcommand, *argv, "--json"]
log.debug("running m2pack: %s", " ".join(cmd))
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, check=False
)
except OSError as exc:
raise SubprocessError(
f"failed to spawn m2pack: {exc}",
error_code="m2pack_spawn_failed",
) from exc
stdout = (proc.stdout or "").strip()
stderr = (proc.stderr or "").strip()
if proc.returncode != 0:
detail = stderr or stdout or f"exit code {proc.returncode}"
raise SubprocessError(
f"m2pack {subcommand} failed (rc={proc.returncode}): {detail}",
error_code="m2pack_failed",
)
if not stdout:
raise SubprocessError(
f"m2pack {subcommand} produced no stdout; stderr={stderr!r}",
error_code="m2pack_empty_output",
)
try:
parsed = json.loads(stdout)
except json.JSONDecodeError as exc:
raise SubprocessError(
f"m2pack {subcommand} returned non-JSON output: {exc}; "
f"first 200 chars={stdout[:200]!r}",
error_code="m2pack_invalid_json",
) from exc
if not isinstance(parsed, dict):
raise SubprocessError(
f"m2pack {subcommand} JSON was not an object: {type(parsed).__name__}",
error_code="m2pack_invalid_json",
)
return parsed

View File

@@ -0,0 +1,47 @@
"""m2pack build: wrap ``m2pack build`` to produce a signed .m2p archive."""
from __future__ import annotations
import argparse
from pathlib import Path
from ..result import Result
from ._m2pack_runner import run_m2pack
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
p = sub.add_parser("build", help="Build a signed .m2p archive from a source directory.")
p.add_argument("--input", required=True, type=Path, help="Client asset source directory.")
p.add_argument("--output", required=True, type=Path, help="Output .m2p archive path.")
p.add_argument("--key", required=True, type=Path, help="Master content key file.")
p.add_argument(
"--sign-secret-key",
required=True,
type=Path,
help="Ed25519 signing secret key file.",
)
p.add_argument("--key-id", type=int, help="Content key id (default 1).")
return p
def run(ctx, args: argparse.Namespace) -> Result:
argv = [
"--input", str(args.input),
"--output", str(args.output),
"--key", str(args.key),
"--sign-secret-key", str(args.sign_secret_key),
]
if args.key_id is not None:
argv.extend(["--key-id", str(args.key_id)])
raw = run_m2pack("build", argv)
status = "built" if raw.get("ok", True) else "failed"
return Result(
command="m2pack build",
ok=bool(raw.get("ok", True)),
status=status,
data={
"artifacts": {"archive_path": str(args.output)},
"m2pack": raw,
},
)

View File

@@ -0,0 +1,45 @@
"""m2pack diff: wrap ``m2pack diff`` to compare directories and/or .m2p archives."""
from __future__ import annotations
import argparse
from pathlib import Path
from ..result import Result
from ._m2pack_runner import run_m2pack
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
p = sub.add_parser(
"diff",
help="Diff two directories and/or .m2p archives (left vs right).",
)
p.add_argument("--left", required=True, type=Path, help="Left side: directory or .m2p archive.")
p.add_argument("--right", required=True, type=Path, help="Right side: directory or .m2p archive.")
return p
def run(ctx, args: argparse.Namespace) -> Result:
argv = ["--left", str(args.left), "--right", str(args.right)]
raw = run_m2pack("diff", argv)
# Best-effort promotion of the diff counts m2pack reports. Fall back
# gracefully when the key is missing or the shape differs.
stats: dict[str, object] = {}
for key in ("added", "removed", "changed", "unchanged"):
value = raw.get(key)
if isinstance(value, int):
stats[f"{key}_count"] = value
elif isinstance(value, list):
stats[f"{key}_count"] = len(value)
ok = bool(raw.get("ok", True))
return Result(
command="m2pack diff",
ok=ok,
status="diffed" if ok else "failed",
data={
"stats": stats,
"m2pack": raw,
},
)

View File

@@ -0,0 +1,57 @@
"""m2pack export-runtime-key: wrap ``m2pack export-runtime-key``.
Emits a launcher runtime-key payload in either ``json`` or ``blob`` form
from the master content key + signing public key. The plan originally
described this as ``--pack --master-key --out``; the real m2pack CLI
uses ``--key --public-key --key-id --format --output``, so we follow the
real tool.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from ..result import Result
from ._m2pack_runner import run_m2pack
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
p = sub.add_parser(
"export-runtime-key",
help="Export a launcher runtime-key payload (json or blob form).",
)
p.add_argument("--key", required=True, type=Path, help="Master content key file.")
p.add_argument("--public-key", required=True, type=Path, help="Ed25519 public key file.")
p.add_argument("--output", required=True, type=Path, help="Output payload path.")
p.add_argument("--key-id", type=int, help="Content key id (default 1).")
p.add_argument(
"--format",
choices=("json", "blob"),
help="Payload format (default json).",
)
return p
def run(ctx, args: argparse.Namespace) -> Result:
argv = [
"--key", str(args.key),
"--public-key", str(args.public_key),
"--output", str(args.output),
]
if args.key_id is not None:
argv.extend(["--key-id", str(args.key_id)])
if args.format is not None:
argv.extend(["--format", args.format])
raw = run_m2pack("export-runtime-key", argv)
ok = bool(raw.get("ok", True))
return Result(
command="m2pack export-runtime-key",
ok=ok,
status="exported" if ok else "failed",
data={
"artifacts": {"runtime_key_path": str(args.output)},
"m2pack": raw,
},
)

View File

@@ -0,0 +1,45 @@
"""m2pack verify: wrap ``m2pack verify`` to validate manifest + optional decrypt."""
from __future__ import annotations
import argparse
from pathlib import Path
from ..result import Result
from ._m2pack_runner import run_m2pack
def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
p = sub.add_parser("verify", help="Verify an .m2p archive (manifest + signature).")
p.add_argument("--archive", required=True, type=Path, help="Path to .m2p archive.")
p.add_argument(
"--public-key",
type=Path,
help="Ed25519 public key file (for signature verification).",
)
p.add_argument(
"--key",
type=Path,
help="Master content key file (enables full-decrypt verification).",
)
return p
def run(ctx, args: argparse.Namespace) -> Result:
argv = ["--archive", str(args.archive)]
if args.public_key is not None:
argv.extend(["--public-key", str(args.public_key)])
if args.key is not None:
argv.extend(["--key", str(args.key)])
raw = run_m2pack("verify", argv)
ok = bool(raw.get("ok", True))
return Result(
command="m2pack verify",
ok=ok,
status="verified" if ok else "failed",
data={
"artifacts": {"archive_path": str(args.archive)},
"m2pack": raw,
},
)

View File

@@ -0,0 +1,51 @@
"""Resolve the m2pack binary for the m2pack wrapper subcommands.
Resolution order:
1. ``M2PACK_BINARY`` environment variable, if set, non-empty, and points at
an existing file.
2. :func:`shutil.which` on ``m2pack``.
If neither works we raise :class:`~metin_release.errors.ValidationError` so
the CLI exits 1 with a clear, actionable message. Import of this module
must never trigger filesystem access — all discovery is runtime.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from .errors import ValidationError
ENV_VAR = "M2PACK_BINARY"
_NOT_FOUND_HINT = (
"m2pack binary not found. Set the M2PACK_BINARY environment variable to "
"an absolute path, or make `m2pack` available on PATH. Build it from "
"https://gitea.jakubkadlec.dev/metin-server/m2pack-secure (see its "
"CMakeLists.txt)."
)
def resolve_m2pack_binary(env: dict[str, str] | None = None) -> Path:
"""Return the resolved path to the m2pack binary or raise ValidationError."""
environ = env if env is not None else os.environ
override = (environ.get(ENV_VAR) or "").strip()
if override:
candidate = Path(override).expanduser()
if candidate.is_file():
return candidate.resolve()
raise ValidationError(
f"{ENV_VAR}={override!r} does not point at an existing file. "
f"{_NOT_FOUND_HINT}",
error_code="m2pack_not_found",
)
which = shutil.which("m2pack")
if which:
return Path(which).resolve()
raise ValidationError(_NOT_FOUND_HINT, error_code="m2pack_not_found")

View File

@@ -0,0 +1,9 @@
"""Thin MCP server wrapping the metin-release CLI.
Exposes each Phase 1 ``release …`` subcommand as an MCP tool. The server
does no business logic of its own: it maps tool input dicts to CLI flags,
spawns ``metin-release <subcommand> --json …`` and returns the parsed JSON
envelope as the tool result.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
"""Module entry: ``python -m metin_release_mcp``."""
from .server import main
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
"""Spawns the metin-release CLI and parses its JSON envelope.
This module is the *only* place that knows how to talk to the real
CLI. The rest of the wrapper treats its output as opaque JSON.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from dataclasses import dataclass
from typing import Any
from .errors import CliNotFoundError, CliUnparseableOutputError
@dataclass
class CliResult:
"""Parsed outcome of one CLI invocation.
``envelope`` is the parsed JSON dict emitted on stdout. ``stderr``
is the raw stderr text, surfaced for diagnostics. ``returncode`` is
the CLI exit code.
"""
envelope: dict[str, Any]
stderr: str
returncode: int
@property
def ok(self) -> bool:
return self.returncode == 0 and bool(self.envelope.get("ok", False))
def resolve_binary(env: dict[str, str] | None = None) -> str:
"""Locate the ``metin-release`` executable.
Preference order:
1. ``METIN_RELEASE_BINARY`` env var, if set and non-empty
2. :func:`shutil.which` on ``metin-release``
"""
environ = env if env is not None else os.environ
override = environ.get("METIN_RELEASE_BINARY", "").strip()
if override:
return override
found = shutil.which("metin-release")
if not found:
raise CliNotFoundError(
"metin-release binary not found; set METIN_RELEASE_BINARY or install the CLI."
)
return found
def run_cli(
argv_tail: list[str],
*,
binary: str | None = None,
runner: Any = None,
) -> CliResult:
"""Spawn the CLI with ``argv_tail`` and parse its JSON stdout.
``runner`` is an injection seam for tests — if provided, it's called
instead of :func:`subprocess.run` with the same arguments, and must
return an object with ``stdout``, ``stderr``, ``returncode``.
"""
bin_path = binary or resolve_binary()
cmd = [bin_path, *argv_tail]
spawn = runner or subprocess.run
try:
proc = spawn(cmd, capture_output=True, text=True, check=False)
except FileNotFoundError as exc:
raise CliNotFoundError(f"cannot spawn metin-release: {exc}") from exc
stdout = (getattr(proc, "stdout", "") or "").strip()
stderr = getattr(proc, "stderr", "") or ""
rc = int(getattr(proc, "returncode", 1))
if not stdout:
raise CliUnparseableOutputError(
f"metin-release produced no stdout (rc={rc}); stderr={stderr.strip()!r}"
)
try:
envelope = json.loads(stdout)
except json.JSONDecodeError as exc:
raise CliUnparseableOutputError(
f"metin-release stdout was not JSON: {exc}; first 200 chars={stdout[:200]!r}"
) from exc
if not isinstance(envelope, dict):
raise CliUnparseableOutputError(
f"metin-release JSON was not an object: {type(envelope).__name__}"
)
return CliResult(envelope=envelope, stderr=stderr, returncode=rc)

View File

@@ -0,0 +1,45 @@
"""Error shapes surfaced by the MCP wrapper.
The wrapper never invents its own error codes. It either propagates the
``{"ok": false, "error": {...}}`` envelope the CLI produced, or wraps a
wrapper-level failure (binary missing, unparseable output, unknown tool)
in one of the classes below.
"""
from __future__ import annotations
class McpWrapperError(Exception):
"""Base class for errors raised by the MCP wrapper itself.
These are *wrapper* problems (no CLI binary, junk on stdout, bad tool
name). CLI errors are passed through untouched as JSON envelopes.
"""
code: str = "mcp_wrapper_error"
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
def to_envelope(self, *, stderr: str | None = None) -> dict:
err: dict = {"code": self.code, "message": self.message}
if stderr:
err["stderr"] = stderr
return {"ok": False, "error": err}
class CliNotFoundError(McpWrapperError):
code = "cli_not_found"
class CliUnparseableOutputError(McpWrapperError):
code = "cli_unparseable_output"
class UnknownToolError(McpWrapperError):
code = "unknown_tool"
class InvalidToolInputError(McpWrapperError):
code = "invalid_tool_input"

View File

@@ -0,0 +1,172 @@
"""MCP stdio server entry point.
Registers each :data:`~metin_release_mcp.tool_defs.TOOL_SPECS` entry as
an MCP tool. On invocation the handler:
1. Validates input via the tool's JSON schema (enforced by the SDK) and
by :func:`~metin_release_mcp.tool_defs.build_cli_args`.
2. Shells out to ``metin-release <subcommand> --json …`` via
:func:`~metin_release_mcp.cli_runner.run_cli`.
3. Returns the parsed JSON envelope as the MCP tool result.
The server contains **no release business logic**. If the CLI returns a
non-zero exit or an ``ok=false`` envelope, the wrapper passes that
envelope back to the caller verbatim, tagging it with the CLI stderr as
diagnostic text.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from typing import Any
from . import __version__
from .errors import (
CliNotFoundError,
CliUnparseableOutputError,
InvalidToolInputError,
McpWrapperError,
UnknownToolError,
)
from .cli_runner import run_cli
from .tool_defs import TOOL_SPECS, TOOLS_BY_NAME, build_cli_args, json_schema
def _build_mcp_server(): # pragma: no cover - imported lazily for tests
from mcp.server import Server
from mcp.types import TextContent, Tool
server = Server("metin-release-mcp", version=__version__)
@server.list_tools()
async def _list_tools() -> list[Tool]:
return [
Tool(
name=spec.name,
description=spec.description,
inputSchema=json_schema(spec),
)
for spec in TOOL_SPECS
]
@server.call_tool()
async def _call_tool(name: str, arguments: dict[str, Any] | None):
envelope, stderr = dispatch(name, arguments or {})
text = json.dumps(envelope, indent=2, sort_keys=False)
content = [TextContent(type="text", text=text)]
if stderr.strip():
content.append(
TextContent(type="text", text=f"metin-release stderr:\n{stderr}")
)
return content, envelope
return server
def dispatch(tool_name: str, payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Dispatch a tool call synchronously.
Returns ``(envelope, stderr)``. On wrapper errors the envelope is
shaped like the CLI's own ``{"ok": false, "error": {...}}`` error
envelope so callers can treat both error sources the same way.
"""
spec = TOOLS_BY_NAME.get(tool_name)
if spec is None:
raise UnknownToolError(
f"unknown tool: {tool_name!r} (known: {sorted(TOOLS_BY_NAME)})"
)
try:
argv_tail = build_cli_args(spec, payload)
except InvalidToolInputError as exc:
return exc.to_envelope(), ""
try:
result = run_cli(argv_tail)
except (CliNotFoundError, CliUnparseableOutputError) as exc:
return exc.to_envelope(stderr=None), ""
return result.envelope, result.stderr
def _print_help() -> None:
lines = [
f"metin-release-mcp {__version__}",
"",
"Thin MCP server wrapping the metin-release CLI over stdio.",
"",
"Usage: metin-release-mcp [--help | --version | --list-tools]",
"",
"With no arguments, runs an MCP server on stdio and registers these tools:",
"",
]
for spec in TOOL_SPECS:
lines.append(f" {spec.name:<26} {spec.description}")
lines.append("")
lines.append("Environment:")
lines.append(
" METIN_RELEASE_BINARY Path to metin-release CLI (else resolved via PATH)"
)
print("\n".join(lines))
def _print_tools() -> None:
out = [
{"name": s.name, "description": s.description, "inputSchema": json_schema(s)}
for s in TOOL_SPECS
]
json.dump(out, sys.stdout, indent=2)
sys.stdout.write("\n")
async def _run_stdio() -> None: # pragma: no cover - exercised only end-to-end
from mcp.server.stdio import stdio_server
server = _build_mcp_server()
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="metin-release-mcp",
description="Thin MCP server wrapping the metin-release CLI.",
add_help=False,
)
parser.add_argument("-h", "--help", action="store_true")
parser.add_argument("--version", action="store_true")
parser.add_argument(
"--list-tools",
action="store_true",
help="Print registered tool schemas as JSON and exit.",
)
args = parser.parse_args(argv)
if args.help:
_print_help()
return 0
if args.version:
print(f"metin-release-mcp {__version__}")
return 0
if args.list_tools:
_print_tools()
return 0
try:
asyncio.run(_run_stdio())
except McpWrapperError as exc:
print(json.dumps(exc.to_envelope()), file=sys.stderr)
return 1
except KeyboardInterrupt: # pragma: no cover
return 130
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View File

@@ -0,0 +1,281 @@
"""Declarative tool specs for the Phase 3 MCP wrapper.
Each tool corresponds 1:1 to a ``metin-release release …`` subcommand. A
single :class:`ToolSpec` list drives three things:
1. The MCP ``list_tools`` response (via :func:`json_schema`)
2. Input validation for ``call_tool``
3. The CLI flag list emitted by :mod:`cli_runner`
Keeping all three in one place is what lets us guarantee the schema
matches the real argparse signature without duplicating it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
FieldType = Literal["string", "integer", "number", "boolean", "path", "array"]
@dataclass(frozen=True)
class FieldSpec:
"""One tool input field.
``name`` is the MCP-facing key (snake_case). ``flag`` is the argparse
flag name; if omitted it's derived from ``name`` by replacing ``_``
with ``-`` and prefixing ``--``. ``kind`` maps to JSON Schema types:
``path`` is a string that the CLI will read as a filesystem path,
``boolean`` is a store_true flag (passed with no value when truthy
and omitted when falsy).
"""
name: str
kind: FieldType
required: bool = False
description: str = ""
flag: str | None = None
default: Any = None
@property
def cli_flag(self) -> str:
return self.flag or f"--{self.name.replace('_', '-')}"
@property
def json_type(self) -> str:
if self.kind == "path":
return "string"
if self.kind == "array":
return "array"
return self.kind
@dataclass(frozen=True)
class ToolSpec:
name: str
subcommand: tuple[str, ...]
description: str
fields: tuple[FieldSpec, ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# Tool catalogue — must stay 1:1 with metin_release.commands.*.add_parser
# ---------------------------------------------------------------------------
_RELEASE = ("release",)
_M2PACK = ("m2pack",)
TOOL_SPECS: tuple[ToolSpec, ...] = (
ToolSpec(
name="release_inspect",
subcommand=_RELEASE + ("inspect",),
description="Scan a client source root and report file/byte counts plus launcher/main-exe presence.",
fields=(
FieldSpec("source", "path", required=True, description="Client root directory."),
),
),
ToolSpec(
name="release_build_manifest",
subcommand=_RELEASE + ("build-manifest",),
description="Build a signed-ready manifest.json for a source tree.",
fields=(
FieldSpec("source", "path", required=True, description="Client source root."),
FieldSpec("version", "string", required=True, description="Release version, e.g. 2026.04.14-1."),
FieldSpec("out", "path", required=True, description="Output manifest.json path."),
FieldSpec("previous", "string", description="Previous release version, if any."),
FieldSpec("notes", "path", description="Path to a release-notes file."),
FieldSpec("launcher", "string", description="Launcher filename (default Metin2Launcher.exe)."),
FieldSpec("created_at", "string", description="Override created_at for reproducible test runs."),
),
),
ToolSpec(
name="release_sign",
subcommand=_RELEASE + ("sign",),
description="Sign a manifest.json with an Ed25519 private key (mode-600 enforced).",
fields=(
FieldSpec("manifest", "path", required=True, description="Path to manifest.json."),
FieldSpec("key", "path", required=True, description="Absolute path to raw 32-byte private key."),
FieldSpec("out", "path", description="Signature output path (default: <manifest>.sig)."),
),
),
ToolSpec(
name="release_diff_remote",
subcommand=_RELEASE + ("diff-remote",),
description="HEAD every manifest blob hash against a base URL and report the missing set.",
fields=(
FieldSpec("manifest", "path", required=True, description="Path to manifest.json."),
FieldSpec("base_url", "string", required=True, description="Remote base URL."),
FieldSpec("timeout", "number", description="Per-request timeout in seconds (default 10)."),
),
),
ToolSpec(
name="release_upload_blobs",
subcommand=_RELEASE + ("upload-blobs",),
description="Rsync a release directory (excluding manifest) to the target.",
fields=(
FieldSpec("release_dir", "path", required=True, description="Local release output directory."),
FieldSpec("rsync_target", "string", required=True, description="rsync destination."),
FieldSpec("dry_run", "boolean", description="Run rsync --dry-run."),
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
),
),
ToolSpec(
name="release_promote",
subcommand=_RELEASE + ("promote",),
description="Promote a staged release by pushing manifest.json + manifest.json.sig.",
fields=(
FieldSpec("release_dir", "path", required=True, description="Local release directory."),
FieldSpec("rsync_target", "string", required=True, description="rsync destination top-level."),
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
FieldSpec("dry_run", "boolean", description="Run rsync --dry-run."),
),
),
ToolSpec(
name="release_verify_public",
subcommand=_RELEASE + ("verify-public",),
description="Fetch manifest.json + signature from a base URL and Ed25519-verify.",
fields=(
FieldSpec("base_url", "string", required=True, description="Remote base URL."),
FieldSpec("public_key", "string", required=True, description="Ed25519 public key: hex or path."),
FieldSpec("sample_blobs", "integer", description="GET and hash-check N random blobs."),
FieldSpec("timeout", "number", description="Per-request timeout in seconds (default 15)."),
),
),
ToolSpec(
name="release_publish",
subcommand=_RELEASE + ("publish",),
description="End-to-end release: build-manifest -> sign -> upload-blobs -> promote -> verify-public.",
fields=(
FieldSpec("source", "path", required=True, description="Client source root."),
FieldSpec("version", "string", required=True, description="Release version."),
FieldSpec("out", "path", required=True, description="Release output directory."),
FieldSpec("key", "path", required=True, description="Signing key path (mode 600)."),
FieldSpec("rsync_target", "string", required=True, description="rsync target for blobs + manifest."),
FieldSpec("base_url", "string", required=True, description="Public base URL for verification."),
FieldSpec("public_key", "string", required=True, description="Public key (hex or file)."),
FieldSpec("previous", "string", description="Previous release version."),
FieldSpec("notes", "path", description="Release notes file."),
FieldSpec("launcher", "string", description="Launcher filename."),
FieldSpec("created_at", "string", description="Override manifest created_at."),
FieldSpec("sample_blobs", "integer", description="verify-public sample count."),
FieldSpec("yes", "boolean", description="Skip interactive confirmation."),
FieldSpec("force", "boolean", description="Allow non-empty output directory."),
FieldSpec("dry_run_upload", "boolean", description="rsync --dry-run for upload + promote."),
),
),
ToolSpec(
name="m2pack_build",
subcommand=_M2PACK + ("build",),
description="Build a signed .m2p archive from a client asset directory via m2pack-secure.",
fields=(
FieldSpec("input", "path", required=True, description="Client asset source directory."),
FieldSpec("output", "path", required=True, description="Output .m2p archive path."),
FieldSpec("key", "path", required=True, description="Master content key file."),
FieldSpec(
"sign_secret_key",
"path",
required=True,
description="Ed25519 signing secret key file.",
),
FieldSpec("key_id", "integer", description="Content key id (default 1)."),
),
),
ToolSpec(
name="m2pack_verify",
subcommand=_M2PACK + ("verify",),
description="Verify an .m2p archive's manifest signature and optionally full decrypt.",
fields=(
FieldSpec("archive", "path", required=True, description="Path to .m2p archive."),
FieldSpec("public_key", "path", description="Ed25519 public key file."),
FieldSpec("key", "path", description="Master content key file for full-decrypt verify."),
),
),
ToolSpec(
name="m2pack_diff",
subcommand=_M2PACK + ("diff",),
description="Diff two directories and/or .m2p archives (left vs right).",
fields=(
FieldSpec("left", "path", required=True, description="Left side: directory or .m2p archive."),
FieldSpec("right", "path", required=True, description="Right side: directory or .m2p archive."),
),
),
ToolSpec(
name="m2pack_export_runtime_key",
subcommand=_M2PACK + ("export-runtime-key",),
description="Export a launcher runtime-key payload (json or blob form) from master+public keys.",
fields=(
FieldSpec("key", "path", required=True, description="Master content key file."),
FieldSpec("public_key", "path", required=True, description="Ed25519 public key file."),
FieldSpec("output", "path", required=True, description="Output payload path."),
FieldSpec("key_id", "integer", description="Content key id (default 1)."),
FieldSpec("format", "string", description="Payload format: json or blob (default json)."),
),
),
)
TOOLS_BY_NAME: dict[str, ToolSpec] = {t.name: t for t in TOOL_SPECS}
def json_schema(spec: ToolSpec) -> dict[str, Any]:
"""Build a JSON Schema for one tool's input object."""
props: dict[str, Any] = {}
required: list[str] = []
for f in spec.fields:
prop: dict[str, Any] = {"type": f.json_type}
if f.description:
prop["description"] = f.description
props[f.name] = prop
if f.required:
required.append(f.name)
schema: dict[str, Any] = {
"type": "object",
"properties": props,
"additionalProperties": False,
}
if required:
schema["required"] = required
return schema
def build_cli_args(spec: ToolSpec, payload: dict[str, Any]) -> list[str]:
"""Translate a tool input dict into a ``metin-release`` argv tail.
Output is ``[*subcommand, "--json", *flags]``. Unknown keys raise
:class:`~metin_release_mcp.errors.InvalidToolInputError`. Required
fields that are missing do the same.
"""
from .errors import InvalidToolInputError
known = {f.name for f in spec.fields}
unknown = set(payload) - known
if unknown:
raise InvalidToolInputError(
f"tool {spec.name} got unknown fields: {sorted(unknown)}"
)
argv: list[str] = [*spec.subcommand, "--json"]
for f in spec.fields:
if f.name not in payload or payload[f.name] is None:
if f.required:
raise InvalidToolInputError(
f"tool {spec.name} missing required field: {f.name}"
)
continue
value = payload[f.name]
if f.kind == "boolean":
if bool(value):
argv.append(f.cli_flag)
continue
if f.kind == "array":
if not isinstance(value, list):
raise InvalidToolInputError(
f"field {f.name} must be an array"
)
for item in value:
argv.extend([f.cli_flag, str(item)])
continue
argv.extend([f.cli_flag, str(value)])
return argv

0
tests/mcp/__init__.py Normal file
View File

32
tests/mcp/conftest.py Normal file
View File

@@ -0,0 +1,32 @@
"""Fixtures for the MCP wrapper test suite."""
from __future__ import annotations
import json
from dataclasses import dataclass
@dataclass
class FakeProc:
stdout: str
stderr: str = ""
returncode: int = 0
def make_runner(envelope: dict | None = None, *, stdout: str | None = None, stderr: str = "", returncode: int = 0):
"""Return a callable compatible with :func:`subprocess.run`.
Records the most recent ``cmd`` on ``runner.calls``.
"""
calls: list[list[str]] = []
def runner(cmd, capture_output=True, text=True, check=False): # noqa: ARG001
calls.append(list(cmd))
if stdout is not None:
s = stdout
else:
s = json.dumps(envelope if envelope is not None else {"ok": True, "command": "x", "status": "ok"})
return FakeProc(stdout=s, stderr=stderr, returncode=returncode)
runner.calls = calls # type: ignore[attr-defined]
return runner

View File

@@ -0,0 +1,86 @@
"""Tests for :mod:`metin_release_mcp.cli_runner`."""
from __future__ import annotations
import json
import pytest
from metin_release_mcp import cli_runner
from metin_release_mcp.errors import CliNotFoundError, CliUnparseableOutputError
from .conftest import make_runner
def test_run_cli_success_parses_envelope():
runner = make_runner({"ok": True, "command": "release inspect", "status": "inspected", "stats": {"file_count": 3}})
result = cli_runner.run_cli(
["release", "inspect", "--json", "--source", "/tmp/x"],
binary="/fake/metin-release",
runner=runner,
)
assert result.ok is True
assert result.envelope["status"] == "inspected"
assert result.envelope["stats"]["file_count"] == 3
assert result.returncode == 0
assert runner.calls[0][0] == "/fake/metin-release"
assert "--json" in runner.calls[0]
def test_run_cli_error_envelope_passed_through():
err = {
"ok": False,
"command": "release sign",
"status": "failed",
"error": {"code": "key_permission", "message": "key must be mode 600"},
}
runner = make_runner(err, stderr="error: key_permission\n", returncode=3)
result = cli_runner.run_cli(["release", "sign"], binary="/fake/mr", runner=runner)
assert result.ok is False
assert result.returncode == 3
assert result.envelope["error"]["code"] == "key_permission"
assert "key_permission" in result.stderr
def test_run_cli_unparseable_output_raises():
runner = make_runner(stdout="not-json-at-all", returncode=0)
with pytest.raises(CliUnparseableOutputError):
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
def test_run_cli_empty_stdout_raises():
runner = make_runner(stdout=" \n", stderr="boom", returncode=2)
with pytest.raises(CliUnparseableOutputError):
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
def test_run_cli_non_object_json_raises():
runner = make_runner(stdout=json.dumps([1, 2, 3]))
with pytest.raises(CliUnparseableOutputError):
cli_runner.run_cli(["release", "inspect"], binary="/fake/mr", runner=runner)
def test_run_cli_file_not_found_raises():
def boom(*args, **kwargs):
raise FileNotFoundError("no such file")
with pytest.raises(CliNotFoundError):
cli_runner.run_cli(["release", "inspect"], binary="/does/not/exist", runner=boom)
def test_resolve_binary_env_override(monkeypatch):
monkeypatch.setenv("METIN_RELEASE_BINARY", "/custom/path/metin-release")
assert cli_runner.resolve_binary() == "/custom/path/metin-release"
def test_resolve_binary_which_fallback(monkeypatch):
monkeypatch.delenv("METIN_RELEASE_BINARY", raising=False)
monkeypatch.setattr(cli_runner.shutil, "which", lambda name: "/usr/bin/metin-release")
assert cli_runner.resolve_binary() == "/usr/bin/metin-release"
def test_resolve_binary_missing_raises(monkeypatch):
monkeypatch.delenv("METIN_RELEASE_BINARY", raising=False)
monkeypatch.setattr(cli_runner.shutil, "which", lambda name: None)
with pytest.raises(CliNotFoundError):
cli_runner.resolve_binary()

View File

@@ -0,0 +1,285 @@
"""Dispatch tests — input dict to CLI argv translation."""
from __future__ import annotations
import json
import pytest
from metin_release_mcp import cli_runner, server
from metin_release_mcp.tool_defs import TOOLS_BY_NAME, build_cli_args
from .conftest import make_runner
def test_inspect_maps_source_to_flag():
spec = TOOLS_BY_NAME["release_inspect"]
argv = build_cli_args(spec, {"source": "/tmp/client"})
assert argv == ["release", "inspect", "--json", "--source", "/tmp/client"]
def test_build_manifest_full_flags():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{
"source": "/src",
"version": "2026.04.14-1",
"out": "/out/manifest.json",
"previous": "2026.04.13-1",
"notes": "/notes.md",
"launcher": "Metin2Launcher.exe",
"created_at": "2026-04-14T00:00:00Z",
},
)
# Required args present in order declared
assert argv[0:3] == ["release", "build-manifest", "--json"]
assert "--source" in argv and argv[argv.index("--source") + 1] == "/src"
assert "--version" in argv and argv[argv.index("--version") + 1] == "2026.04.14-1"
assert "--out" in argv and argv[argv.index("--out") + 1] == "/out/manifest.json"
assert "--previous" in argv and argv[argv.index("--previous") + 1] == "2026.04.13-1"
assert "--created-at" in argv
assert argv[argv.index("--created-at") + 1] == "2026-04-14T00:00:00Z"
def test_build_manifest_omits_missing_optionals():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{"source": "/src", "version": "v1", "out": "/m.json"},
)
assert "--previous" not in argv
assert "--notes" not in argv
assert "--launcher" not in argv
assert "--created-at" not in argv
def test_none_valued_optional_is_omitted():
spec = TOOLS_BY_NAME["release_build_manifest"]
argv = build_cli_args(
spec,
{"source": "/s", "version": "v", "out": "/m", "previous": None, "notes": None},
)
assert "--previous" not in argv
assert "--notes" not in argv
def test_boolean_flag_true_adds_flag_without_value():
spec = TOOLS_BY_NAME["release_upload_blobs"]
argv = build_cli_args(
spec,
{
"release_dir": "/rel",
"rsync_target": "user@host:/srv/updates",
"dry_run": True,
"yes": True,
},
)
assert "--dry-run" in argv
assert "--yes" in argv
# No value should follow --dry-run
i = argv.index("--dry-run")
# next element either end of list or another flag
assert i == len(argv) - 1 or argv[i + 1].startswith("--")
def test_boolean_flag_false_omits_flag():
spec = TOOLS_BY_NAME["release_upload_blobs"]
argv = build_cli_args(
spec,
{"release_dir": "/rel", "rsync_target": "tgt", "dry_run": False, "yes": False},
)
assert "--dry-run" not in argv
assert "--yes" not in argv
def test_path_with_spaces_passes_through_as_single_argv_element():
spec = TOOLS_BY_NAME["release_inspect"]
argv = build_cli_args(spec, {"source": "/tmp/has spaces/client root"})
# argv is a list — no shell involved, so spaces stay in one element
assert "/tmp/has spaces/client root" in argv
assert argv.count("--source") == 1
def test_diff_remote_numeric_timeout_serialised_as_string():
spec = TOOLS_BY_NAME["release_diff_remote"]
argv = build_cli_args(
spec,
{"manifest": "/m.json", "base_url": "https://x/", "timeout": 7.5},
)
assert argv[argv.index("--timeout") + 1] == "7.5"
def test_publish_mixes_booleans_and_strings():
spec = TOOLS_BY_NAME["release_publish"]
argv = build_cli_args(
spec,
{
"source": "/src",
"version": "v1",
"out": "/out",
"key": "/k",
"rsync_target": "t",
"base_url": "https://x/",
"public_key": "deadbeef",
"force": True,
"dry_run_upload": True,
"yes": False,
},
)
assert "--force" in argv
assert "--dry-run-upload" in argv
assert "--yes" not in argv
# ---------------------------------------------------------------------------
# server.dispatch integration against a stubbed CLI runner
# ---------------------------------------------------------------------------
@pytest.fixture
def stub_runner(monkeypatch):
def install(envelope=None, *, stdout=None, stderr="", returncode=0):
runner = make_runner(envelope, stdout=stdout, stderr=stderr, returncode=returncode)
monkeypatch.setattr(
server,
"run_cli",
lambda argv_tail: cli_runner.run_cli(
argv_tail, binary="/fake/metin-release", runner=runner
),
)
return runner
return install
def test_dispatch_success_returns_envelope(stub_runner):
runner = stub_runner(
{"ok": True, "command": "release inspect", "status": "inspected", "stats": {"file_count": 42}}
)
envelope, stderr = server.dispatch("release_inspect", {"source": "/tmp/x"})
assert envelope["ok"] is True
assert envelope["stats"]["file_count"] == 42
# the runner saw --json
assert "--json" in runner.calls[0]
assert "--source" in runner.calls[0]
assert "/tmp/x" in runner.calls[0]
def test_dispatch_cli_error_envelope_passes_through(stub_runner):
err = {
"ok": False,
"command": "release sign",
"status": "failed",
"error": {"code": "key_permission", "message": "mode 644"},
}
stub_runner(err, stderr="error\n", returncode=3)
envelope, stderr = server.dispatch(
"release_sign", {"manifest": "/m.json", "key": "/k"}
)
assert envelope == err
assert "error" in stderr
def test_dispatch_cli_binary_missing_returns_wrapper_error(monkeypatch):
def boom(argv_tail):
from metin_release_mcp.errors import CliNotFoundError
raise CliNotFoundError("metin-release binary not found")
monkeypatch.setattr(server, "run_cli", boom)
envelope, stderr = server.dispatch("release_inspect", {"source": "/x"})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "cli_not_found"
def test_dispatch_invalid_input_returns_wrapper_error():
envelope, _ = server.dispatch("release_inspect", {})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "invalid_tool_input"
def test_m2pack_build_translates_all_flags():
spec = TOOLS_BY_NAME["m2pack_build"]
argv = build_cli_args(
spec,
{
"input": "/src",
"output": "/out/a.m2p",
"key": "/ck",
"sign_secret_key": "/sk",
"key_id": 2,
},
)
assert argv[0:3] == ["m2pack", "build", "--json"]
assert "--input" in argv and argv[argv.index("--input") + 1] == "/src"
assert "--output" in argv and argv[argv.index("--output") + 1] == "/out/a.m2p"
assert "--sign-secret-key" in argv
assert argv[argv.index("--sign-secret-key") + 1] == "/sk"
assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "2"
def test_m2pack_verify_omits_optional_keys():
spec = TOOLS_BY_NAME["m2pack_verify"]
argv = build_cli_args(spec, {"archive": "/a.m2p"})
assert argv == ["m2pack", "verify", "--json", "--archive", "/a.m2p"]
def test_m2pack_diff_requires_both_sides():
from metin_release_mcp.errors import InvalidToolInputError
spec = TOOLS_BY_NAME["m2pack_diff"]
with pytest.raises(InvalidToolInputError):
build_cli_args(spec, {"left": "/L"})
argv = build_cli_args(spec, {"left": "/L", "right": "/R"})
assert argv == ["m2pack", "diff", "--json", "--left", "/L", "--right", "/R"]
def test_m2pack_export_runtime_key_with_format():
spec = TOOLS_BY_NAME["m2pack_export_runtime_key"]
argv = build_cli_args(
spec,
{
"key": "/ck",
"public_key": "/pk",
"output": "/out",
"format": "blob",
"key_id": 4,
},
)
assert argv[0:3] == ["m2pack", "export-runtime-key", "--json"]
assert "--format" in argv and argv[argv.index("--format") + 1] == "blob"
assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "4"
def test_dispatch_m2pack_build_through_fake_runner(stub_runner):
runner = stub_runner(
{"ok": True, "command": "m2pack build", "status": "built", "artifacts": {"archive_path": "/x.m2p"}}
)
envelope, _ = server.dispatch(
"m2pack_build",
{
"input": "/src",
"output": "/x.m2p",
"key": "/ck",
"sign_secret_key": "/sk",
},
)
assert envelope["ok"] is True
call = runner.calls[0]
assert "m2pack" in call and "build" in call
assert "--json" in call
assert "--input" in call and "/src" in call
assert "--sign-secret-key" in call and "/sk" in call
def test_dispatch_unparseable_output_returns_wrapper_error(monkeypatch):
def boom(argv_tail):
from metin_release_mcp.errors import CliUnparseableOutputError
raise CliUnparseableOutputError("not json")
monkeypatch.setattr(server, "run_cli", boom)
envelope, _ = server.dispatch("release_inspect", {"source": "/x"})
assert envelope["ok"] is False
assert envelope["error"]["code"] == "cli_unparseable_output"

View File

@@ -0,0 +1,152 @@
"""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})

View File

@@ -0,0 +1,68 @@
"""Tests for the m2pack binary discovery helper."""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
from metin_release.errors import ValidationError
from metin_release.m2pack_binary import ENV_VAR, resolve_m2pack_binary
def _make_stub(tmp_path: Path, name: str = "m2pack") -> Path:
stub = tmp_path / name
stub.write_text("#!/bin/sh\necho '{}'\n")
stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return stub
def test_env_var_override_wins(tmp_path: Path, monkeypatch):
stub = _make_stub(tmp_path)
# Scrub PATH so shutil.which can't resolve m2pack
monkeypatch.setenv("PATH", "/nonexistent")
monkeypatch.setenv(ENV_VAR, str(stub))
resolved = resolve_m2pack_binary()
assert resolved == stub.resolve()
def test_env_var_empty_falls_through_to_path(tmp_path: Path, monkeypatch):
stub = _make_stub(tmp_path)
monkeypatch.setenv(ENV_VAR, " ") # blank-ish
monkeypatch.setenv("PATH", str(tmp_path))
resolved = resolve_m2pack_binary()
assert resolved == stub.resolve()
def test_env_var_pointing_nowhere_raises(tmp_path: Path, monkeypatch):
monkeypatch.setenv(ENV_VAR, str(tmp_path / "does-not-exist"))
monkeypatch.setenv("PATH", "/nonexistent")
with pytest.raises(ValidationError) as exc:
resolve_m2pack_binary()
assert exc.value.error_code == "m2pack_not_found"
def test_path_fallback_used_when_env_unset(tmp_path: Path, monkeypatch):
stub = _make_stub(tmp_path)
monkeypatch.delenv(ENV_VAR, raising=False)
monkeypatch.setenv("PATH", str(tmp_path))
resolved = resolve_m2pack_binary()
assert resolved == stub.resolve()
def test_missing_binary_raises_validation_error(monkeypatch):
monkeypatch.delenv(ENV_VAR, raising=False)
monkeypatch.setenv("PATH", "/nonexistent")
with pytest.raises(ValidationError) as exc:
resolve_m2pack_binary()
assert exc.value.error_code == "m2pack_not_found"
assert "M2PACK_BINARY" in str(exc.value)
def test_custom_env_mapping_parameter(tmp_path: Path):
stub = _make_stub(tmp_path)
fake_env = {ENV_VAR: str(stub)}
resolved = resolve_m2pack_binary(env=fake_env)
assert resolved == stub.resolve()

View File

@@ -0,0 +1,307 @@
"""Tests for the m2pack wrapper subcommands.
All tests use a stub binary — a small Python script that echoes a canned
JSON envelope based on the subcommand name — pointed at via the
``M2PACK_BINARY`` env var. The real m2pack-secure binary is never invoked.
"""
from __future__ import annotations
import json
import stat
import sys
from pathlib import Path
import pytest
from metin_release.cli import main as cli_main
STUB_TEMPLATE = r"""#!{python}
import json
import sys
argv = sys.argv[1:]
sub = argv[0] if argv else "unknown"
MODE = {mode!r}
if MODE == "fail":
sys.stderr.write("boom\n")
sys.exit(2)
if MODE == "nonjson":
sys.stdout.write("not json at all\n")
sys.exit(0)
if MODE == "notobject":
sys.stdout.write(json.dumps([1, 2, 3]) + "\n")
sys.exit(0)
# mode == "ok": echo a canned envelope per subcommand
if sub == "build":
env = {{"ok": True, "command": "build", "stats": {{"files": 3, "bytes": 12345}}}}
elif sub == "verify":
env = {{"ok": True, "command": "verify", "signature": "valid"}}
elif sub == "diff":
env = {{"ok": True, "command": "diff", "added": ["a"], "removed": [], "changed": ["b", "c"], "unchanged": 7}}
elif sub == "export-runtime-key":
env = {{"ok": True, "command": "export-runtime-key", "key_id": 1, "format": "json"}}
else:
env = {{"ok": True, "command": sub}}
# Record argv so the test can assert translation
import os
log = os.environ.get("M2PACK_STUB_LOG")
if log:
with open(log, "a") as fh:
fh.write(json.dumps(argv) + "\n")
sys.stdout.write(json.dumps(env) + "\n")
sys.exit(0)
"""
def _install_stub(tmp_path: Path, monkeypatch, mode: str = "ok") -> tuple[Path, Path]:
stub = tmp_path / "m2pack_stub.py"
stub.write_text(STUB_TEMPLATE.format(python=sys.executable, mode=mode))
stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
log = tmp_path / "stub.log"
monkeypatch.setenv("M2PACK_BINARY", str(stub))
monkeypatch.setenv("M2PACK_STUB_LOG", str(log))
return stub, log
def _run_cli(argv: list[str], capsys) -> dict:
rc = cli_main(argv)
out = capsys.readouterr().out
envelope = json.loads(out)
return {"rc": rc, "env": envelope}
def _read_stub_call(log: Path) -> list[str]:
lines = [ln for ln in log.read_text().splitlines() if ln.strip()]
assert lines, "stub was never invoked"
return json.loads(lines[-1])
# ---------------------------------------------------------------------------
# Success paths
# ---------------------------------------------------------------------------
def test_build_success(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
(tmp_path / "in").mkdir()
key = tmp_path / "k.hex"
key.write_text("ff")
sk = tmp_path / "sk.hex"
sk.write_text("aa")
out = tmp_path / "out.m2p"
r = _run_cli(
[
"--json",
"m2pack",
"build",
"--input", str(tmp_path / "in"),
"--output", str(out),
"--key", str(key),
"--sign-secret-key", str(sk),
"--key-id", "7",
],
capsys,
)
assert r["rc"] == 0
assert r["env"]["ok"] is True
assert r["env"]["command"] == "m2pack build"
assert r["env"]["status"] == "built"
assert r["env"]["artifacts"]["archive_path"] == str(out)
assert r["env"]["m2pack"]["stats"]["files"] == 3
call = _read_stub_call(log)
assert call[0] == "build"
assert "--input" in call and str(tmp_path / "in") in call
assert "--output" in call and str(out) in call
assert "--key" in call and str(key) in call
assert "--sign-secret-key" in call and str(sk) in call
assert "--key-id" in call and "7" in call
assert "--json" in call
def test_build_omits_optional_key_id(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
(tmp_path / "in").mkdir()
(tmp_path / "k").write_text("a")
(tmp_path / "sk").write_text("b")
_run_cli(
[
"--json", "m2pack", "build",
"--input", str(tmp_path / "in"),
"--output", str(tmp_path / "o.m2p"),
"--key", str(tmp_path / "k"),
"--sign-secret-key", str(tmp_path / "sk"),
],
capsys,
)
call = _read_stub_call(log)
assert "--key-id" not in call
def test_verify_success_without_keys(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
archive = tmp_path / "a.m2p"
archive.write_bytes(b"x")
r = _run_cli(
["--json", "m2pack", "verify", "--archive", str(archive)], capsys
)
assert r["rc"] == 0
assert r["env"]["status"] == "verified"
assert r["env"]["m2pack"]["signature"] == "valid"
call = _read_stub_call(log)
assert call[0] == "verify"
assert "--public-key" not in call
assert "--key" not in call
def test_verify_with_public_and_content_keys(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
archive = tmp_path / "a.m2p"
archive.write_bytes(b"x")
pk = tmp_path / "pub"
pk.write_text("ff")
ck = tmp_path / "ck"
ck.write_text("aa")
_run_cli(
[
"--json", "m2pack", "verify",
"--archive", str(archive),
"--public-key", str(pk),
"--key", str(ck),
],
capsys,
)
call = _read_stub_call(log)
assert "--public-key" in call and str(pk) in call
assert "--key" in call and str(ck) in call
def test_diff_success_promotes_counts(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
left = tmp_path / "L"
left.mkdir()
right = tmp_path / "R.m2p"
right.write_bytes(b"x")
r = _run_cli(
[
"--json", "m2pack", "diff",
"--left", str(left),
"--right", str(right),
],
capsys,
)
assert r["rc"] == 0
assert r["env"]["status"] == "diffed"
stats = r["env"]["stats"]
assert stats["added_count"] == 1
assert stats["removed_count"] == 0
assert stats["changed_count"] == 2
assert stats["unchanged_count"] == 7
call = _read_stub_call(log)
assert call[0] == "diff"
def test_export_runtime_key_success(tmp_path, monkeypatch, capsys):
_, log = _install_stub(tmp_path, monkeypatch)
key = tmp_path / "ck"
key.write_text("aa")
pk = tmp_path / "pk"
pk.write_text("ff")
out = tmp_path / "runtime.json"
r = _run_cli(
[
"--json", "m2pack", "export-runtime-key",
"--key", str(key),
"--public-key", str(pk),
"--output", str(out),
"--key-id", "3",
"--format", "blob",
],
capsys,
)
assert r["rc"] == 0
assert r["env"]["status"] == "exported"
assert r["env"]["artifacts"]["runtime_key_path"] == str(out)
call = _read_stub_call(log)
assert call[0] == "export-runtime-key"
assert "--format" in call and "blob" in call
assert "--key-id" in call and "3" in call
def test_export_runtime_key_rejects_bad_format(tmp_path, monkeypatch, capsys):
_install_stub(tmp_path, monkeypatch)
with pytest.raises(SystemExit):
cli_main(
[
"--json", "m2pack", "export-runtime-key",
"--key", str(tmp_path / "k"),
"--public-key", str(tmp_path / "p"),
"--output", str(tmp_path / "o"),
"--format", "yaml",
]
)
# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------
def test_nonzero_exit_maps_to_subprocess_failed(tmp_path, monkeypatch, capsys):
_install_stub(tmp_path, monkeypatch, mode="fail")
(tmp_path / "in").mkdir()
(tmp_path / "k").write_text("a")
(tmp_path / "sk").write_text("b")
rc = cli_main(
[
"--json", "m2pack", "build",
"--input", str(tmp_path / "in"),
"--output", str(tmp_path / "o.m2p"),
"--key", str(tmp_path / "k"),
"--sign-secret-key", str(tmp_path / "sk"),
],
)
assert rc == 1
env = json.loads(capsys.readouterr().out)
assert env["ok"] is False
assert env["error"]["code"] == "m2pack_failed"
def test_nonjson_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys):
_install_stub(tmp_path, monkeypatch, mode="nonjson")
archive = tmp_path / "a.m2p"
archive.write_bytes(b"x")
rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)])
assert rc == 1
env = json.loads(capsys.readouterr().out)
assert env["ok"] is False
assert env["error"]["code"] == "m2pack_invalid_json"
def test_json_array_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys):
_install_stub(tmp_path, monkeypatch, mode="notobject")
archive = tmp_path / "a.m2p"
archive.write_bytes(b"x")
rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)])
assert rc == 1
env = json.loads(capsys.readouterr().out)
assert env["error"]["code"] == "m2pack_invalid_json"
def test_missing_binary_raises_validation_error(tmp_path, monkeypatch, capsys):
monkeypatch.delenv("M2PACK_BINARY", raising=False)
monkeypatch.setenv("PATH", "/nonexistent")
archive = tmp_path / "a.m2p"
archive.write_bytes(b"x")
rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)])
assert rc == 1
env = json.loads(capsys.readouterr().out)
assert env["ok"] is False
assert env["error"]["code"] == "m2pack_not_found"