feat(mcp): 增加游戏内方块调试桥接

新增 stdio MCP server 和 mcp 子命令,通过调试会话状态文件连接内置 TCP bridge,并提供 place_block 工具。

Bridge 将请求投递到服务端 System 的游戏线程执行,限制请求大小与队列长度,并按会话端口安全清理状态文件和连接。
This commit is contained in:
2026-07-21 21:04:45 +08:00
parent 94b1b9a1f5
commit 1a7bc6aca1
15 changed files with 1375 additions and 11 deletions

View File

@@ -0,0 +1,438 @@
# -*- coding: utf-8 -*-
import json
import os
import socket
import threading
import traceback
import mod.server.extraServerApi as serverApi
from .Config import GET_MCP_BRIDGE_STATE_PATH
try:
basestring
except NameError:
basestring = str
try:
integer_types = (int, long)
except NameError:
integer_types = (int,)
_BRIDGE = None
_SERVER_ACTIVE = False
_SERVER_TASKS = []
_SERVER_TASKS_LOCK = threading.Lock()
MAX_PENDING_TASKS = 32
MAX_REQUEST_BYTES = 1024 * 1024
class BridgeError(Exception):
pass
def _is_int(value):
return isinstance(value, integer_types) and not isinstance(value, bool)
def _event_is_set(event):
if hasattr(event, "is_set"):
return event.is_set()
return event.isSet()
def _create_daemon_thread(target, args=()):
thread = threading.Thread(target=target, args=args)
try:
thread.daemon = True
except Exception:
thread.setDaemon(True)
return thread
def _to_text(data):
if isinstance(data, bytes):
return data.decode("utf-8")
return data
def _send_json_line(sock, payload):
data = json.dumps(payload, separators=(",", ":")) + "\n"
if not isinstance(data, bytes):
data = data.encode("utf-8")
sock.sendall(data)
def _require_params(params):
if not isinstance(params, dict):
raise BridgeError("place_block params must be an object")
for key in ("x", "y", "z"):
if key not in params or not _is_int(params.get(key)):
raise BridgeError("place_block.%s must be an integer" % key)
name = params.get("name")
if not isinstance(name, basestring) or not name.strip() or ":" not in name:
raise BridgeError("place_block.name must be a non-empty namespaced block id")
dimension = params.get("dimension", 0)
if not _is_int(dimension):
raise BridgeError("place_block.dimension must be an integer")
old_block_handling = params.get("old_block_handling", 0)
if not _is_int(old_block_handling) or old_block_handling not in (0, 1, 2):
raise BridgeError("place_block.old_block_handling must be 0, 1, or 2")
aux = params.get("aux", None)
states = params.get("states", None)
if aux is not None and states is not None:
raise BridgeError("place_block.aux and place_block.states cannot both be provided")
if aux is not None and not _is_int(aux):
raise BridgeError("place_block.aux must be an integer")
if states is not None and not isinstance(states, dict):
raise BridgeError("place_block.states must be an object")
is_legacy = params.get("is_legacy", True)
if not isinstance(is_legacy, bool):
raise BridgeError("place_block.is_legacy must be a boolean")
update_neighbors = params.get("update_neighbors", True)
if not isinstance(update_neighbors, bool):
raise BridgeError("place_block.update_neighbors must be a boolean")
return {
"x": params["x"],
"y": params["y"],
"z": params["z"],
"name": name,
"dimension": dimension,
"aux": aux,
"states": states,
"old_block_handling": old_block_handling,
"is_legacy": is_legacy,
"update_neighbors": update_neighbors,
}
def _place_block_on_server(request):
name = request["name"]
aux = request["aux"]
states = request["states"]
if states is not None:
block_state = serverApi.GetEngineCompFactory().CreateBlockState(serverApi.GetLevelId())
aux = block_state.GetBlockAuxValueFromStates(name, states)
if aux == -1:
raise BridgeError("invalid block states for %s" % name)
elif aux is None:
aux = 0
x = request["x"]
y = request["y"]
z = request["z"]
dimension = request["dimension"]
old_block_handling = request["old_block_handling"]
is_legacy = request["is_legacy"]
update_neighbors = request["update_neighbors"]
block_info = serverApi.GetEngineCompFactory().CreateBlockInfo(serverApi.GetLevelId())
changed = block_info.SetBlockNew(
(x, y, z),
{"name": name, "aux": aux},
old_block_handling,
dimension,
is_legacy,
update_neighbors,
)
return {
"changed": bool(changed),
"position": [x, y, z],
"dimension": dimension,
"block": {"name": name, "aux": aux},
"old_block_handling": old_block_handling,
"is_legacy": is_legacy,
"update_neighbors": update_neighbors,
}
def _place_block(params):
request = _require_params(params)
task = {
"request": request,
"done": threading.Event(),
"cancelled": False,
"started": False,
}
with _SERVER_TASKS_LOCK:
if not _SERVER_ACTIVE:
raise BridgeError("server system is not ready")
if len(_SERVER_TASKS) >= MAX_PENDING_TASKS:
raise BridgeError("server request queue is full")
_SERVER_TASKS.append(task)
task["done"].wait(5.0)
if not _event_is_set(task["done"]):
with _SERVER_TASKS_LOCK:
if task["started"]:
started = True
else:
started = False
task["cancelled"] = True
if task in _SERVER_TASKS:
_SERVER_TASKS.remove(task)
if started:
task["done"].wait()
else:
raise BridgeError("place_block timed out waiting for server thread")
if "error" in task:
raise BridgeError(task["error"])
return task["result"]
def UPDATE():
with _SERVER_TASKS_LOCK:
tasks = list(_SERVER_TASKS)
del _SERVER_TASKS[:]
for task in tasks:
with _SERVER_TASKS_LOCK:
if task["cancelled"]:
task["done"].set()
continue
task["started"] = True
try:
task["result"] = _place_block_on_server(task["request"])
except BridgeError as err:
task["error"] = str(err)
except Exception as err:
traceback.print_exc()
task["error"] = str(err)
finally:
task["done"].set()
def _cancel_server_tasks():
with _SERVER_TASKS_LOCK:
tasks = list(_SERVER_TASKS)
del _SERVER_TASKS[:]
for task in tasks:
task["cancelled"] = True
task["error"] = "server stopped before executing request"
task["done"].set()
class MCPBridge(object):
def __init__(self, state_path):
self.state_path = state_path
self.sock = None
self.lock = threading.Lock()
self.running = False
self.connections = set()
self.accept_thread = None
self.client_threads = set()
self.port = None
self.tmp_path = None
def start(self):
with self.lock:
if self.running:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(4)
sock.settimeout(0.2)
self.sock = sock
self.running = True
port = sock.getsockname()[1]
self.port = port
self._write_state(port)
print("[MCPBridge] Listening on 127.0.0.1:%d" % port)
thread = _create_daemon_thread(self._accept_loop)
with self.lock:
self.accept_thread = thread
thread.start()
def close(self):
with self.lock:
sock = self.sock
self.sock = None
self.running = False
connections = list(self.connections)
self.connections.clear()
accept_thread = self.accept_thread
self.accept_thread = None
client_threads = list(self.client_threads)
self.client_threads.clear()
if sock:
try:
sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
sock.close()
except Exception:
pass
for conn in connections:
try:
conn.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
conn.close()
except Exception:
pass
if accept_thread and accept_thread.is_alive():
accept_thread.join(1.0)
for thread in client_threads:
if thread.is_alive():
thread.join(1.0)
self._delete_state()
def _write_state(self, port):
state_dir = os.path.dirname(self.state_path)
if state_dir and not os.path.isdir(state_dir):
os.makedirs(state_dir)
tmp_path = "%s.%d.tmp" % (self.state_path, port)
self.tmp_path = tmp_path
data = json.dumps(
{"version": 1, "host": "127.0.0.1", "port": port},
separators=(",", ":"),
)
with open(tmp_path, "w") as fp:
fp.write(data)
os.rename(tmp_path, self.state_path)
self.tmp_path = None
def _delete_state(self):
try:
if self.tmp_path and os.path.exists(self.tmp_path):
os.remove(self.tmp_path)
self.tmp_path = None
if not os.path.exists(self.state_path) or self.port is None:
return
with open(self.state_path, "r") as fp:
state = json.load(fp)
if state.get("host") == "127.0.0.1" and state.get("port") == self.port:
os.remove(self.state_path)
except Exception:
traceback.print_exc()
finally:
self.port = None
def _accept_loop(self):
while True:
with self.lock:
sock = self.sock
running = self.running
if not running or sock is None:
return
try:
conn, _addr = sock.accept()
except socket.timeout:
continue
except socket.error:
with self.lock:
if not self.running:
return
traceback.print_exc()
return
except Exception:
with self.lock:
if not self.running:
return
traceback.print_exc()
return
with self.lock:
if not self.running:
conn.close()
return
self.connections.add(conn)
thread = _create_daemon_thread(self._handle_client, (conn,))
self.client_threads.add(thread)
conn.settimeout(0.2)
thread.start()
def _handle_client(self, conn):
try:
pending = b""
while True:
with self.lock:
if not self.running:
return
try:
chunk = conn.recv(4096)
except socket.timeout:
continue
if not chunk:
return
pending += chunk
if len(pending) > MAX_REQUEST_BYTES:
raise BridgeError("request line exceeds maximum size")
while b"\n" in pending:
raw_line, pending = pending.split(b"\n", 1)
line = _to_text(raw_line).strip()
if line:
self._handle_line(conn, line)
except Exception:
with self.lock:
if self.running:
traceback.print_exc()
finally:
with self.lock:
self.connections.discard(conn)
self.client_threads.discard(threading.currentThread())
try:
conn.close()
except Exception:
pass
def _handle_line(self, conn, line):
request_id = None
try:
request = json.loads(line)
if not isinstance(request, dict):
raise BridgeError("request must be an object")
request_id = request.get("id")
method = request.get("method")
if method != "place_block":
raise BridgeError("unknown MCP bridge method: %s" % method)
result = _place_block(request.get("params", {}))
_send_json_line(conn, {"id": request_id, "ok": True, "result": result})
except BridgeError as err:
_send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)})
except Exception as err:
traceback.print_exc()
_send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)})
def ON_SERVER_INIT():
global _BRIDGE, _SERVER_ACTIVE
state_path = GET_MCP_BRIDGE_STATE_PATH()
if not state_path:
return
if _BRIDGE:
return
bridge = MCPBridge(state_path)
with _SERVER_TASKS_LOCK:
_SERVER_ACTIVE = True
try:
bridge.start()
except Exception:
with _SERVER_TASKS_LOCK:
_SERVER_ACTIVE = False
bridge.close()
raise
_BRIDGE = bridge
def ON_SERVER_EXIT():
global _BRIDGE, _SERVER_ACTIVE
bridge = _BRIDGE
_BRIDGE = None
with _SERVER_TASKS_LOCK:
_SERVER_ACTIVE = False
_cancel_server_tasks()
if bridge:
bridge.close()