mcp: add server stdio entry and --help

This commit is contained in:
Jan Nedbal
2026-04-14 19:33:33 +02:00
parent 7117d25a9a
commit 79df538226

View File

@@ -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 <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:
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())