feat(mcp): 增加游戏内方块调试桥接
新增 stdio MCP server 和 mcp 子命令,通过调试会话状态文件连接内置 TCP bridge,并提供 place_block 工具。 Bridge 将请求投递到服务端 System 的游戏线程执行,限制请求大小与队列长度,并按会话端口安全清理状态文件和连接。
This commit is contained in:
438
debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py
Normal file
438
debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py
Normal 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()
|
||||
Reference in New Issue
Block a user