mcp: add server stdio entry and --help
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user