Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c32224ff0 | ||
|
|
22f67a0589 | ||
|
|
1201ec50d2 | ||
|
|
c4c65e2fe7 | ||
|
|
a289cd7c25 | ||
|
|
197c5ba8a2 | ||
|
|
ae0cbb7e9b | ||
|
|
70d20f0f18 | ||
|
|
50ea80d64b | ||
|
|
b2283b0c3f | ||
|
|
a8ae85c0a4 | ||
|
|
79df538226 | ||
|
|
7117d25a9a | ||
|
|
860face1d1 | ||
|
|
d55291e75e |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
minor for new subcommands or additive flags, patch for fixes that preserve the
|
||||||
contract.
|
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
|
## [0.1.0] - 2026-04-14
|
||||||
|
|
||||||
First Phase 1 drop. Implements the minimum asset release path the
|
First Phase 1 drop. Implements the minimum asset release path the
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -31,3 +31,29 @@ Add `--json` to get a machine-parseable envelope on stdout. Exit codes:
|
|||||||
| 4 | reserved (ERP sync, Phase 2+) |
|
| 4 | reserved (ERP sync, Phase 2+) |
|
||||||
|
|
||||||
See `docs/cli.md` for the full command reference.
|
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.
|
||||||
|
|||||||
75
docs/cli.md
75
docs/cli.md
@@ -1,6 +1,7 @@
|
|||||||
# metin-release — CLI reference
|
# 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
|
## Top-level flags
|
||||||
|
|
||||||
@@ -145,3 +146,75 @@ metin-release release publish \
|
|||||||
[--created-at ...] [--sample-blobs N] \
|
[--created-at ...] [--sample-blobs N] \
|
||||||
[--yes] [--force] [--dry-run-upload]
|
[--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
120
docs/mcp.md
Normal 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.
|
||||||
@@ -22,9 +22,13 @@ dev = [
|
|||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"pytest-mock>=3",
|
"pytest-mock>=3",
|
||||||
]
|
]
|
||||||
|
mcp = [
|
||||||
|
"mcp>=1.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
metin-release = "metin_release.cli:main"
|
metin-release = "metin_release.cli:main"
|
||||||
|
metin-release-mcp = "metin_release_mcp.server:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from .commands import (
|
|||||||
build_manifest,
|
build_manifest,
|
||||||
diff_remote,
|
diff_remote,
|
||||||
inspect,
|
inspect,
|
||||||
|
m2pack_build,
|
||||||
|
m2pack_diff,
|
||||||
|
m2pack_export_runtime_key,
|
||||||
|
m2pack_verify,
|
||||||
promote,
|
promote,
|
||||||
publish,
|
publish,
|
||||||
sign,
|
sign,
|
||||||
@@ -68,6 +72,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
sp = mod.add_parser(rsub)
|
sp = mod.add_parser(rsub)
|
||||||
_add_common_flags(sp)
|
_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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +96,13 @@ _COMMAND_MAP: dict[tuple[str, str], tuple[str, CommandFn]] = {
|
|||||||
("release", "promote"): ("release promote", promote.run),
|
("release", "promote"): ("release promote", promote.run),
|
||||||
("release", "verify-public"): ("release verify-public", verify_public.run),
|
("release", "verify-public"): ("release verify-public", verify_public.run),
|
||||||
("release", "publish"): ("release publish", publish.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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from . import (
|
|||||||
build_manifest,
|
build_manifest,
|
||||||
diff_remote,
|
diff_remote,
|
||||||
inspect,
|
inspect,
|
||||||
|
m2pack_build,
|
||||||
|
m2pack_diff,
|
||||||
|
m2pack_export_runtime_key,
|
||||||
|
m2pack_verify,
|
||||||
promote,
|
promote,
|
||||||
publish,
|
publish,
|
||||||
sign,
|
sign,
|
||||||
@@ -15,6 +19,10 @@ __all__ = [
|
|||||||
"build_manifest",
|
"build_manifest",
|
||||||
"diff_remote",
|
"diff_remote",
|
||||||
"inspect",
|
"inspect",
|
||||||
|
"m2pack_build",
|
||||||
|
"m2pack_diff",
|
||||||
|
"m2pack_export_runtime_key",
|
||||||
|
"m2pack_verify",
|
||||||
"promote",
|
"promote",
|
||||||
"publish",
|
"publish",
|
||||||
"sign",
|
"sign",
|
||||||
|
|||||||
78
src/metin_release/commands/_m2pack_runner.py
Normal file
78
src/metin_release/commands/_m2pack_runner.py
Normal 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
|
||||||
47
src/metin_release/commands/m2pack_build.py
Normal file
47
src/metin_release/commands/m2pack_build.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
45
src/metin_release/commands/m2pack_diff.py
Normal file
45
src/metin_release/commands/m2pack_diff.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
57
src/metin_release/commands/m2pack_export_runtime_key.py
Normal file
57
src/metin_release/commands/m2pack_export_runtime_key.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
45
src/metin_release/commands/m2pack_verify.py
Normal file
45
src/metin_release/commands/m2pack_verify.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
51
src/metin_release/m2pack_binary.py
Normal file
51
src/metin_release/m2pack_binary.py
Normal 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")
|
||||||
9
src/metin_release_mcp/__init__.py
Normal file
9
src/metin_release_mcp/__init__.py
Normal 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"
|
||||||
5
src/metin_release_mcp/__main__.py
Normal file
5
src/metin_release_mcp/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Module entry: ``python -m metin_release_mcp``."""
|
||||||
|
|
||||||
|
from .server import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
95
src/metin_release_mcp/cli_runner.py
Normal file
95
src/metin_release_mcp/cli_runner.py
Normal 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)
|
||||||
45
src/metin_release_mcp/errors.py
Normal file
45
src/metin_release_mcp/errors.py
Normal 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"
|
||||||
172
src/metin_release_mcp/server.py
Normal file
172
src/metin_release_mcp/server.py
Normal 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())
|
||||||
281
src/metin_release_mcp/tool_defs.py
Normal file
281
src/metin_release_mcp/tool_defs.py
Normal 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
0
tests/mcp/__init__.py
Normal file
32
tests/mcp/conftest.py
Normal file
32
tests/mcp/conftest.py
Normal 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
|
||||||
86
tests/mcp/test_cli_runner.py
Normal file
86
tests/mcp/test_cli_runner.py
Normal 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()
|
||||||
285
tests/mcp/test_tool_dispatch.py
Normal file
285
tests/mcp/test_tool_dispatch.py
Normal 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"
|
||||||
152
tests/mcp/test_tool_schema.py
Normal file
152
tests/mcp/test_tool_schema.py
Normal 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})
|
||||||
68
tests/test_m2pack_binary.py
Normal file
68
tests/test_m2pack_binary.py
Normal 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()
|
||||||
307
tests/test_m2pack_commands.py
Normal file
307
tests/test_m2pack_commands.py
Normal 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"
|
||||||
Reference in New Issue
Block a user