仓库初始化

This commit is contained in:
lwt
2026-05-22 00:16:08 +08:00
commit 95c96dce42
118 changed files with 23005 additions and 0 deletions
View File
+8
View File
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""Run ``python -m mcp`` for the Server Manager MCP stdio server."""
from mcp.server import run_stdio_server
if __name__ == "__main__":
run_stdio_server()
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Minimal MCP stdio server for Server Manager.
The transport is newline-delimited JSON-RPC over stdin/stdout. Keep stdout
strictly reserved for MCP messages; diagnostics go to stderr.
"""
from __future__ import annotations
import json
import sys
from typing import Any, Dict, Optional
from mcp.tools import ServerManagerToolbox
PROTOCOL_VERSION = "2025-11-25"
SERVER_NAME = "server-manager"
SERVER_VERSION = "1.0.0"
JsonDict = Dict[str, Any]
class McpStdioServer:
def __init__(self, server_root: Optional[str] = None, config_path: Optional[str] = None,
cookie: Optional[str] = None):
self.toolbox = ServerManagerToolbox(server_root=server_root, config_path=config_path, cookie=cookie)
self._tools = self.toolbox.tool_map()
def serve_forever(self) -> None:
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
response = self.handle_message(message)
except Exception as e:
response = self._error(None, -32700, f"Parse error: {e}")
if response is not None:
self._send(response)
def handle_message(self, message: JsonDict) -> Optional[JsonDict]:
if not isinstance(message, dict):
return self._error(None, -32600, "Invalid Request")
request_id = message.get("id")
method = message.get("method")
if not method:
return self._error(request_id, -32600, "Invalid Request")
# Notifications do not receive responses.
if request_id is None:
if method in {
"notifications/initialized",
"notifications/cancelled",
"notifications/progress",
}:
return None
return None
params = message.get("params") or {}
try:
if method == "initialize":
return self._result(request_id, self._initialize_result(params))
if method == "ping":
return self._result(request_id, {})
if method == "tools/list":
return self._result(request_id, {
"tools": [tool.to_mcp_tool() for tool in self._tools.values()],
})
if method == "tools/call":
return self._result(request_id, self._call_tool(params))
return self._error(request_id, -32601, f"Method not found: {method}")
except Exception as e:
return self._error(request_id, -32603, str(e))
def _initialize_result(self, params: JsonDict) -> JsonDict:
requested = str(params.get("protocolVersion") or PROTOCOL_VERSION)
protocol = requested if requested <= PROTOCOL_VERSION else PROTOCOL_VERSION
return {
"protocolVersion": protocol,
"capabilities": {
"tools": {"listChanged": False},
},
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION,
},
"instructions": (
"Use the server_manager_* tools to inspect and operate local Server Manager "
"projects. Operations that start/stop/create servers can modify local state."
),
}
def _call_tool(self, params: JsonDict) -> JsonDict:
name = params.get("name")
if not isinstance(name, str) or not name:
return {
"content": [{"type": "text", "text": "tools/call requires params.name"}],
"isError": True,
}
arguments = params.get("arguments") or {}
if not isinstance(arguments, dict):
return {
"content": [{"type": "text", "text": "tools/call params.arguments must be an object"}],
"isError": True,
}
return self.toolbox.call_tool(name, arguments)
def _send(self, response: JsonDict) -> None:
sys.stdout.write(json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def _result(self, request_id: Any, result: JsonDict) -> JsonDict:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def _error(self, request_id: Any, code: int, message: str) -> JsonDict:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
}
def run_stdio_server(server_root: Optional[str] = None, config_path: Optional[str] = None,
cookie: Optional[str] = None) -> None:
McpStdioServer(server_root=server_root, config_path=config_path, cookie=cookie).serve_forever()
+502
View File
@@ -0,0 +1,502 @@
# -*- coding: utf-8 -*-
"""MCP tool definitions backed by Server Manager services."""
from __future__ import annotations
import contextlib
import io
import json
import os
import subprocess
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from entrypoints.cli.manager import ServerManagerCLI
from services.commands import (
check_nodes_status,
flatten_server_dirs,
generate_start_config,
get_server_list,
read_merged_config,
read_text_file_best_effort,
resolve_log_file_path,
REBAR3_CMD,
)
from services.rg_search import rg_search_directory
JsonDict = Dict[str, Any]
@dataclass(frozen=True)
class ToolDefinition:
name: str
title: str
description: str
input_schema: JsonDict
handler: Callable[[JsonDict], JsonDict]
annotations: Optional[JsonDict] = None
def to_mcp_tool(self) -> JsonDict:
data: JsonDict = {
"name": self.name,
"title": self.title,
"description": self.description,
"inputSchema": self.input_schema,
}
if self.annotations:
data["annotations"] = self.annotations
return data
class ToolError(Exception):
"""Expected tool failure returned as an MCP tool error."""
def _schema(properties: JsonDict, required: Optional[List[str]] = None) -> JsonDict:
return {
"type": "object",
"properties": properties,
"required": required or [],
"additionalProperties": False,
}
def _text_result(data: Any, *, is_error: bool = False) -> JsonDict:
text = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, indent=2)
result: JsonDict = {
"content": [{"type": "text", "text": text}],
"isError": is_error,
}
if not is_error and not isinstance(data, str):
result["structuredContent"] = data
return result
def _mask_sensitive(config: JsonDict) -> JsonDict:
masked = dict(config)
for key in list(masked):
lowered = key.lower()
if "pass" in lowered or "password" in lowered or "secret" in lowered:
masked[key] = "***"
return masked
def _truncate(text: str, max_chars: int) -> str:
if len(text) <= max_chars:
return text
return text[:max_chars] + f"\n... <truncated {len(text) - max_chars} chars>"
class ServerManagerToolbox:
"""Server Manager operations exposed to MCP tools."""
def __init__(self, server_root: Optional[str] = None, config_path: Optional[str] = None,
cookie: Optional[str] = None):
self.server_root = server_root
self.config_path = config_path
self.cookie = cookie
def list_tools(self) -> List[ToolDefinition]:
return [
ToolDefinition(
name="server_manager_list_servers",
title="List Servers",
description="List server directories grouped by type. Optionally include online status.",
input_schema=_schema({
"server_root": {"type": "string", "description": "Optional server project root override."},
"config_path": {"type": "string", "description": "Optional config file override."},
"include_status": {"type": "boolean", "description": "Whether to check Erlang node status."},
"target_ip": {"type": "string", "description": "Optional target host/IP for status checks."},
}),
handler=self.list_servers,
annotations={"readOnlyHint": True, "destructiveHint": False},
),
ToolDefinition(
name="server_manager_check_status",
title="Check Node Status",
description="Check whether one or more Erlang server nodes are online.",
input_schema=_schema({
"servers": {
"type": "array",
"items": {"type": "string"},
"description": "Server directory or node names. If omitted, all discovered servers are checked.",
},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
"target_ip": {"type": "string"},
"cookie": {"type": "string"},
}),
handler=self.check_status,
annotations={"readOnlyHint": True, "destructiveHint": False},
),
ToolDefinition(
name="server_manager_read_config",
title="Read Config",
description="Read merged Server Manager config, optionally including a specific server kv.config.",
input_schema=_schema({
"server": {"type": "string", "description": "Optional server directory name."},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
"include_sensitive": {
"type": "boolean",
"description": "Return passwords/secrets instead of masking them. Defaults to false.",
},
}),
handler=self.read_config,
annotations={"readOnlyHint": True, "destructiveHint": False},
),
ToolDefinition(
name="server_manager_create_server",
title="Create Server",
description="Create a game/login/center/cross/client server from project templates.",
input_schema=_schema({
"server_type": {
"type": "string",
"enum": ["game", "login", "center", "cross", "client"],
},
"server_id": {"type": "integer"},
"prefix": {"type": "string"},
"db_host": {"type": "string"},
"db_user": {"type": "string"},
"db_pass": {"type": "string"},
"db_port": {"type": "integer"},
"login_node": {"type": "string"},
"center_node": {"type": "string"},
"server_name": {"type": "string"},
"tcp_port": {"type": "integer"},
"http_port": {"type": "integer"},
"open_time": {"type": "string"},
"overwrite": {"type": "boolean"},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server_type", "server_id"]),
handler=self.create_server,
annotations={"readOnlyHint": False, "destructiveHint": False, "idempotentHint": False},
),
ToolDefinition(
name="server_manager_regenerate_config",
title="Regenerate Sys Config",
description="Regenerate sys.config for a server from default/tool/kv config.",
input_schema=_schema({
"server": {"type": "string"},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server"]),
handler=self.regenerate_config,
annotations={"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True},
),
ToolDefinition(
name="server_manager_start_server",
title="Start Server",
description="Start a server in a background terminal/window. Foreground mode is not exposed over MCP.",
input_schema=_schema({
"server": {"type": "string"},
"quick": {"type": "boolean", "description": "Use direct erl quick start instead of rebar3 shell."},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server"]),
handler=self.start_server,
annotations={"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True},
),
ToolDefinition(
name="server_manager_stop_server",
title="Stop Server",
description="Stop a running Erlang server node.",
input_schema=_schema({
"server": {"type": "string"},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server"]),
handler=self.stop_server,
annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True},
),
ToolDefinition(
name="server_manager_compile",
title="Compile Server",
description="Run a rebar3 compile command and return captured output.",
input_schema=_schema({
"target": {
"type": "string",
"enum": ["all", "code", "proto", "table", "tbllog"],
"description": "Compile target. Defaults to all.",
},
"server_type": {
"type": "string",
"enum": ["game", "login"],
"description": "Compile profile family. Defaults to game.",
},
"timeout_seconds": {"type": "integer", "description": "Timeout, max 3600. Defaults to 1200."},
"max_output_chars": {"type": "integer", "description": "Output truncation size. Defaults to 20000."},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}),
handler=self.compile,
annotations={"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True},
),
ToolDefinition(
name="server_manager_search_logs",
title="Search Logs",
description="Search a server log directory using ripgrep when available.",
input_schema=_schema({
"server": {"type": "string"},
"pattern": {"type": "string"},
"case_sensitive": {"type": "boolean"},
"use_regex": {"type": "boolean"},
"whole_word": {"type": "boolean"},
"glob": {
"type": "array",
"items": {"type": "string"},
"description": "Optional rg glob filters, e.g. ['*.log*'].",
},
"max_output_chars": {"type": "integer"},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server", "pattern"]),
handler=self.search_logs,
annotations={"readOnlyHint": True, "destructiveHint": False},
),
ToolDefinition(
name="server_manager_read_log",
title="Read Log",
description="Read a specific log file from a server log directory with a byte limit.",
input_schema=_schema({
"server": {"type": "string"},
"base_filename": {
"type": "string",
"description": "Base log file name, e.g. error.log or info.log.",
},
"date_yyyymmdd": {"type": "string", "description": "Optional date suffix, e.g. 20260516."},
"subdir": {"type": "string", "description": "Optional subdir under log, e.g. tag_log."},
"max_bytes": {"type": "integer", "description": "Read limit. Defaults to 1048576."},
"server_root": {"type": "string"},
"config_path": {"type": "string"},
}, required=["server", "base_filename"]),
handler=self.read_log,
annotations={"readOnlyHint": True, "destructiveHint": False},
),
]
def tool_map(self) -> Dict[str, ToolDefinition]:
return {tool.name: tool for tool in self.list_tools()}
def call_tool(self, name: str, arguments: Optional[JsonDict]) -> JsonDict:
tool = self.tool_map().get(name)
if not tool:
return _text_result(f"未知工具: {name}", is_error=True)
try:
payload = tool.handler(dict(arguments or {}))
return _text_result(payload)
except ToolError as e:
return _text_result(str(e), is_error=True)
except Exception:
return _text_result(traceback.format_exc(), is_error=True)
def _arg(self, arguments: JsonDict, key: str, default: Any = None) -> Any:
value = arguments.get(key, default)
if value is None:
return default
return value
def _make_cli(self, arguments: JsonDict) -> tuple[ServerManagerCLI, str]:
server_root = self._arg(arguments, "server_root", self.server_root)
config_path = self._arg(arguments, "config_path", self.config_path)
cookie = self._arg(arguments, "cookie", self.cookie)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
cli = ServerManagerCLI(server_root=server_root, config_path=config_path, quiet=True)
if cookie:
cli.cookie = str(cookie)
return cli, buf.getvalue()
def _capture_cli_call(self, cli: ServerManagerCLI, func: Callable[[], Any]) -> tuple[Any, str]:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = func()
return result, buf.getvalue()
def list_servers(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
servers = get_server_list(cli.run_dir)
status = None
if bool(arguments.get("include_status", False)):
names = flatten_server_dirs(cli.run_dir)
status = check_nodes_status(
names,
cli.cookie,
target_ip=arguments.get("target_ip"),
erl_path=cli.config.get("erl_path"),
)
return {
"server_root": cli.server_root,
"run_dir": cli.run_dir,
"servers": servers,
"status": status,
"output": init_output.strip(),
}
def check_status(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
servers = arguments.get("servers") or flatten_server_dirs(cli.run_dir)
if not isinstance(servers, list):
raise ToolError("servers 必须是字符串数组")
results = check_nodes_status(
[str(item) for item in servers],
str(arguments.get("cookie") or cli.cookie),
target_ip=arguments.get("target_ip"),
erl_path=cli.config.get("erl_path"),
)
return {
"server_root": cli.server_root,
"run_dir": cli.run_dir,
"status": results,
"output": init_output.strip(),
}
def read_config(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = arguments.get("server")
config = read_merged_config(cli.server_root, server_dir=server, run_dir=cli.run_dir)
if not bool(arguments.get("include_sensitive", False)):
config = _mask_sensitive(config)
return {
"server_root": cli.server_root,
"run_dir": cli.run_dir,
"server": server,
"config": config,
"output": init_output.strip(),
}
def create_server(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
success, output = self._capture_cli_call(
cli,
lambda: cli.create_server_cmd(
server_type=str(arguments["server_type"]),
server_id=int(arguments["server_id"]),
prefix=arguments.get("prefix"),
db_host=arguments.get("db_host"),
db_user=arguments.get("db_user"),
db_pass=arguments.get("db_pass"),
db_port=arguments.get("db_port"),
login_node=arguments.get("login_node"),
center_node=arguments.get("center_node"),
server_name=arguments.get("server_name"),
tcp_port=arguments.get("tcp_port"),
http_port=arguments.get("http_port"),
open_time=arguments.get("open_time"),
overwrite=bool(arguments.get("overwrite", False)),
),
)
return {"success": bool(success), "output": (init_output + output).strip()}
def regenerate_config(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = str(arguments["server"])
success, output = self._capture_cli_call(cli, lambda: cli.regenerate_config(server))
return {"success": bool(success), "server": server, "output": (init_output + output).strip()}
def start_server(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = str(arguments["server"])
quick = bool(arguments.get("quick", False))
success, output = self._capture_cli_call(
cli,
lambda: cli.start_server(server, use_rebar=not quick, foreground=False),
)
return {
"success": bool(success),
"server": server,
"quick": quick,
"output": (init_output + output).strip(),
}
def stop_server(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = str(arguments["server"])
success, output = self._capture_cli_call(cli, lambda: cli.stop_server(server))
return {"success": bool(success), "server": server, "output": (init_output + output).strip()}
def compile(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
target = str(arguments.get("target") or "all")
server_type = str(arguments.get("server_type") or "game")
timeout = min(max(int(arguments.get("timeout_seconds") or 1200), 1), 3600)
max_chars = min(max(int(arguments.get("max_output_chars") or 20000), 1000), 200000)
profile = "login_server_dev" if server_type == "login" else "game_server_dev"
cmd_map = {
"all": f"{REBAR3_CMD} as {profile} compile",
"code": f"{REBAR3_CMD} as {profile} compile",
"proto": f"{REBAR3_CMD} protobuf compile",
"table": f"{REBAR3_CMD} cache compile",
"tbllog": f"{REBAR3_CMD} tbllog compile",
}
cmd = cmd_map.get(target)
if not cmd:
raise ToolError(f"未知编译目标: {target}")
env = os.environ.copy()
env["ESCRIPT_EMULATOR"] = "erl"
proc = subprocess.run(
cmd,
shell=True,
cwd=cli.server_root,
env=env,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
)
return {
"success": proc.returncode == 0,
"return_code": proc.returncode,
"command": cmd,
"server_root": cli.server_root,
"output": _truncate((init_output + "\n" + (proc.stdout or "")).strip(), max_chars),
}
def search_logs(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = str(arguments["server"])
search_root = Path(cli.run_dir) / server / "log"
max_chars = min(max(int(arguments.get("max_output_chars") or 20000), 1000), 200000)
out, err = rg_search_directory(
search_root=search_root,
pattern=str(arguments["pattern"]),
case_sensitive=bool(arguments.get("case_sensitive", False)),
use_regex=bool(arguments.get("use_regex", False)),
whole_word=bool(arguments.get("whole_word", False)),
glob_globs=arguments.get("glob"),
)
if err:
raise ToolError(err)
return {
"server": server,
"search_root": str(search_root),
"matches": _truncate(out, max_chars),
"output": init_output.strip(),
}
def read_log(self, arguments: JsonDict) -> JsonDict:
cli, init_output = self._make_cli(arguments)
server = str(arguments["server"])
max_bytes = min(max(int(arguments.get("max_bytes") or 1048576), 1024), 10 * 1024 * 1024)
path = resolve_log_file_path(
cli.run_dir,
server,
str(arguments["base_filename"]),
date_yyyymmdd=arguments.get("date_yyyymmdd"),
subdir=arguments.get("subdir") or "",
)
content, err = read_text_file_best_effort(path, max_bytes=max_bytes)
if err:
raise ToolError(err)
return {
"server": server,
"path": str(path),
"content": content,
"output": init_output.strip(),
}