From 860face1d13d34666a697eb94ea8f73174ebaa09 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 19:33:18 +0200 Subject: [PATCH] mcp: add cli runner that spawns metin-release and parses json --- src/metin_release_mcp/cli_runner.py | 95 +++++++++++++++++++++++++++++ src/metin_release_mcp/errors.py | 45 ++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/metin_release_mcp/cli_runner.py create mode 100644 src/metin_release_mcp/errors.py diff --git a/src/metin_release_mcp/cli_runner.py b/src/metin_release_mcp/cli_runner.py new file mode 100644 index 0000000..cf8a522 --- /dev/null +++ b/src/metin_release_mcp/cli_runner.py @@ -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) diff --git a/src/metin_release_mcp/errors.py b/src/metin_release_mcp/errors.py new file mode 100644 index 0000000..9852682 --- /dev/null +++ b/src/metin_release_mcp/errors.py @@ -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"