mcp: add cli runner that spawns metin-release and parses json
This commit is contained in:
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"
|
||||
Reference in New Issue
Block a user