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 Registers each :data:`~metin_release_mcp.tool_defs.TOOL_SPECS` entry as
subsequent commits. This stub only advertises the package version so an MCP tool. On invocation the handler:
``python -m metin_release_mcp`` doesn't crash during the scaffold
commit. 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 from __future__ import annotations
import argparse
import asyncio
import json
import sys
from typing import Any
from . import __version__ 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: 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 return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())