diff --git a/src/metin_release_mcp/server.py b/src/metin_release_mcp/server.py index f9f201a..77d7909 100644 --- a/src/metin_release_mcp/server.py +++ b/src/metin_release_mcp/server.py @@ -1,16 +1,172 @@ -"""MCP server entry point (scaffold). +"""MCP stdio server entry point. -The real stdio server, tool registration, and dispatch logic land in -subsequent commits. This stub only advertises the package version so -``python -m metin_release_mcp`` doesn't crash during the scaffold -commit. +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 --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: - print(f"metin-release-mcp {__version__} (scaffold)") + 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())