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
|
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())
|
||||||
|
|||||||
Reference in New Issue
Block a user