91b878f347
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle) - Created weapon resource files with balanced stats - Wired Pistol + Rifle into player.tscn replacing Kenney blasters - Fixed WASD input (matched input map action names) - Fixed Escape key toggles mouse capture - Added Audio autoload singleton - Fixed broken sound asset paths across all scripts - Fixed all scene ext_resource text paths (nested under assets/kenney-fps/) - Fixed all .import source_file paths - Deleted stale .import files (will be regenerated by editor on open)
636 lines
21 KiB
GDScript
636 lines
21 KiB
GDScript
extends Node
|
|
|
|
# KEEP IN SYNC: src/utils/bridge-protocol.ts implements the same framing on the
|
|
# Node side. Any change here MUST be mirrored there (and vice versa).
|
|
#
|
|
# Wire format: 4-byte big-endian length prefix + UTF-8 JSON payload.
|
|
# Max frame size 16 MiB; oversize frames close the offending peer.
|
|
|
|
# Port is baked into this script at inject time by BridgeManager.inject — the
|
|
# integer literal below is rewritten in the project copy. The 9900 here is the
|
|
# source-of-truth default that ships with the script so it remains runnable
|
|
# standalone (e.g. validate, manual debugging).
|
|
const PORT := 9900 # MCP_BRIDGE_PORT_BAKED
|
|
const MAX_FRAME_BYTES := 16 * 1024 * 1024
|
|
const FRAME_HEADER_BYTES := 4
|
|
|
|
class PeerState:
|
|
extends RefCounted
|
|
var stream: StreamPeerTCP
|
|
var buffer: PackedByteArray = PackedByteArray()
|
|
var expected_len: int = -1 # -1 = waiting on header
|
|
var handling: bool = false # true while a command is awaiting a response
|
|
|
|
var tcp_server: TCPServer
|
|
var session_token: String = ""
|
|
var _peers: Array = [] # Array[PeerState]
|
|
var _shutting_down: bool = false # One-shot: set true in shutdown(); never reset (autoload is recreated on next session)
|
|
|
|
func _ready() -> void:
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
session_token = OS.get_environment("MCP_SESSION_TOKEN")
|
|
tcp_server = TCPServer.new()
|
|
var err = tcp_server.listen(PORT, "127.0.0.1")
|
|
if err != OK:
|
|
push_error("McpBridge: Failed to listen on port %d (error %d)" % [PORT, err])
|
|
else:
|
|
print("McpBridge: Listening on TCP port %d" % PORT)
|
|
|
|
if OS.get_environment("MCP_BACKGROUND") == "1":
|
|
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_NO_FOCUS, true)
|
|
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_MOUSE_PASSTHROUGH, true)
|
|
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
|
DisplayServer.window_set_position(Vector2i(-9999, -9999))
|
|
print("McpBridge: Background mode active - window hidden, physical input blocked")
|
|
|
|
func _process(_delta: float) -> void:
|
|
if tcp_server == null or not tcp_server.is_listening():
|
|
return
|
|
|
|
while tcp_server.is_connection_available():
|
|
var stream := tcp_server.take_connection()
|
|
if stream == null:
|
|
break
|
|
stream.set_no_delay(true)
|
|
var peer := PeerState.new()
|
|
peer.stream = stream
|
|
_peers.append(peer)
|
|
|
|
# Backwards iteration so remove_at() doesn't shift entries we haven't seen
|
|
# yet, and avoids the O(n) cost of Array.erase() per removal.
|
|
var i := _peers.size()
|
|
while i > 0:
|
|
i -= 1
|
|
var peer = _peers[i]
|
|
_poll_peer(peer)
|
|
if peer.stream == null or peer.stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
|
_peers.remove_at(i)
|
|
|
|
func _poll_peer(peer: PeerState) -> void:
|
|
peer.stream.poll()
|
|
var status := peer.stream.get_status()
|
|
if status != StreamPeerTCP.STATUS_CONNECTED:
|
|
return
|
|
|
|
var available := peer.stream.get_available_bytes()
|
|
if available > 0:
|
|
var chunk: Array = peer.stream.get_partial_data(available)
|
|
# get_partial_data returns [error, PackedByteArray]
|
|
if chunk[0] == OK:
|
|
peer.buffer.append_array(chunk[1])
|
|
|
|
while true:
|
|
if peer.expected_len < 0:
|
|
if peer.buffer.size() < FRAME_HEADER_BYTES:
|
|
return
|
|
# Read u32 BE header.
|
|
var header := peer.buffer.slice(0, FRAME_HEADER_BYTES)
|
|
var b0 := int(header[0])
|
|
var b1 := int(header[1])
|
|
var b2 := int(header[2])
|
|
var b3 := int(header[3])
|
|
peer.expected_len = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
|
|
peer.buffer = peer.buffer.slice(FRAME_HEADER_BYTES)
|
|
if peer.expected_len > MAX_FRAME_BYTES:
|
|
push_error("McpBridge: Frame header exceeds limit (%d), closing peer" % peer.expected_len)
|
|
peer.stream.disconnect_from_host()
|
|
peer.stream = null
|
|
return
|
|
|
|
if peer.handling:
|
|
return
|
|
if peer.buffer.size() < peer.expected_len:
|
|
return
|
|
|
|
var frame_bytes := peer.buffer.slice(0, peer.expected_len)
|
|
peer.buffer = peer.buffer.slice(peer.expected_len)
|
|
peer.expected_len = -1
|
|
|
|
var data := frame_bytes.get_string_from_utf8().strip_edges()
|
|
peer.handling = true
|
|
_dispatch_command(peer, data)
|
|
# _dispatch_command awaits internally on async branches (input, run_script,
|
|
# screenshot, shutdown), so control returns here at the first inner await.
|
|
# `peer.handling` is the gate that blocks re-entry; it is cleared by
|
|
# `_send_response` once the handler completes.
|
|
|
|
# INVARIANT: every code path through this function and its handlers must
|
|
# eventually reach `_send_response`. `peer.handling` is set to true by the
|
|
# caller (`_poll_peer`) before dispatch and cleared inside `_send_response`.
|
|
# A handler that exits without calling `_send_response` will deadlock the
|
|
# peer — the next frame will never be polled. When adding a new branch,
|
|
# ensure the early-exit calls `_send_response` with an error payload.
|
|
func _dispatch_command(peer: PeerState, data: String) -> void:
|
|
if not data.begins_with("{"):
|
|
_send_response(peer, {"error": "Non-JSON frame (expected a JSON command object)"})
|
|
return
|
|
|
|
var json = JSON.new()
|
|
var err = json.parse(data)
|
|
if err != OK:
|
|
_send_response(peer, {"error": "Invalid JSON: %s" % json.get_error_message()})
|
|
return
|
|
|
|
var payload = json.data
|
|
if typeof(payload) != TYPE_DICTIONARY:
|
|
_send_response(peer, {"error": "Expected JSON object"})
|
|
return
|
|
|
|
var command = payload.get("command", "")
|
|
match command:
|
|
"input":
|
|
var actions = payload.get("actions", [])
|
|
if typeof(actions) != TYPE_ARRAY:
|
|
_send_response(peer, {"error": "actions must be an array"})
|
|
return
|
|
if actions.is_empty():
|
|
_send_response(peer, {"error": "actions array is empty"})
|
|
return
|
|
await _handle_input(peer, actions)
|
|
"get_ui_elements":
|
|
_handle_get_ui_elements(peer, payload)
|
|
"run_script":
|
|
await _handle_run_script(peer, payload)
|
|
"screenshot":
|
|
await _handle_screenshot(peer, payload)
|
|
"shutdown":
|
|
await _handle_shutdown(peer)
|
|
"ping":
|
|
_send_response(peer, {"status": "pong", "session_token": session_token, "project_path": ProjectSettings.globalize_path("res://")})
|
|
_:
|
|
_send_response(peer, {"error": "Unknown command: %s" % command})
|
|
|
|
# --- Screenshot ---
|
|
|
|
func _handle_screenshot(peer: PeerState, payload: Dictionary = {}) -> void:
|
|
await RenderingServer.frame_post_draw
|
|
|
|
var viewport := get_viewport()
|
|
if viewport == null:
|
|
_send_response(peer, {"error": "No viewport available"})
|
|
return
|
|
|
|
var image := viewport.get_texture().get_image()
|
|
if image == null:
|
|
_send_response(peer, {"error": "Failed to capture viewport image"})
|
|
return
|
|
|
|
var timestamp := str(Time.get_unix_time_from_system()).replace(".", "_")
|
|
var screenshot_dir := ProjectSettings.globalize_path("res://.mcp/screenshots")
|
|
DirAccess.make_dir_recursive_absolute(screenshot_dir)
|
|
var file_path := screenshot_dir.path_join("screenshot_%s.png" % timestamp)
|
|
|
|
var save_err := image.save_png(file_path)
|
|
if save_err != OK:
|
|
_send_response(peer, {"error": "Failed to save screenshot (error %d)" % save_err})
|
|
return
|
|
|
|
var safe_path := file_path.replace("\\", "/")
|
|
var response: Dictionary = {
|
|
"path": safe_path,
|
|
"width": image.get_width(),
|
|
"height": image.get_height(),
|
|
}
|
|
|
|
var preview_max_width: int = int(payload.get("preview_max_width", 0))
|
|
var preview_max_height: int = int(payload.get("preview_max_height", 0))
|
|
if preview_max_width > 0 and preview_max_height > 0:
|
|
var scale: float = min(
|
|
1.0,
|
|
min(
|
|
float(preview_max_width) / float(image.get_width()),
|
|
float(preview_max_height) / float(image.get_height())
|
|
)
|
|
)
|
|
var preview_width: int = max(1, int(floor(float(image.get_width()) * scale)))
|
|
var preview_height: int = max(1, int(floor(float(image.get_height()) * scale)))
|
|
# Full image already saved to disk — resize in-place to avoid a redundant copy
|
|
image.resize(preview_width, preview_height, Image.INTERPOLATE_LANCZOS)
|
|
var preview_path: String = screenshot_dir.path_join("screenshot_%s_preview.png" % timestamp)
|
|
var preview_err: Error = image.save_png(preview_path)
|
|
if preview_err != OK:
|
|
_send_response(peer, {"error": "Failed to save screenshot preview (error %d)" % preview_err})
|
|
return
|
|
response["preview_path"] = preview_path.replace("\\", "/")
|
|
response["preview_width"] = preview_width
|
|
response["preview_height"] = preview_height
|
|
|
|
_send_response(peer, response)
|
|
|
|
# --- Input Simulation ---
|
|
|
|
func _handle_input(peer: PeerState, actions: Array) -> void:
|
|
var processed := 0
|
|
var error_msg := ""
|
|
|
|
for action in actions:
|
|
if typeof(action) != TYPE_DICTIONARY:
|
|
error_msg = "Action at index %d is not an object" % processed
|
|
break
|
|
|
|
var type = action.get("type", "")
|
|
match type:
|
|
"key":
|
|
var result = _inject_key(action)
|
|
if result != "":
|
|
error_msg = "Action %d (key): %s" % [processed, result]
|
|
break
|
|
"mouse_button":
|
|
var result = _inject_mouse_button(action)
|
|
if result != "":
|
|
error_msg = "Action %d (mouse_button): %s" % [processed, result]
|
|
break
|
|
"mouse_motion":
|
|
_inject_mouse_motion(action)
|
|
"action":
|
|
var result = _inject_action(action)
|
|
if result != "":
|
|
error_msg = "Action %d (action): %s" % [processed, result]
|
|
break
|
|
"click_element":
|
|
var result = _inject_click_element(action)
|
|
if result != "":
|
|
error_msg = "Action %d (click_element): %s" % [processed, result]
|
|
break
|
|
"wait":
|
|
var ms = action.get("ms", 0)
|
|
if typeof(ms) == TYPE_FLOAT or typeof(ms) == TYPE_INT:
|
|
if ms > 0:
|
|
await get_tree().create_timer(ms / 1000.0).timeout
|
|
else:
|
|
error_msg = "Action %d (wait): ms must be a number" % processed
|
|
break
|
|
_:
|
|
error_msg = "Action %d: unknown type '%s'" % [processed, type]
|
|
break
|
|
|
|
processed += 1
|
|
|
|
# Allow queued input events to dispatch and any signal handlers
|
|
# (and their runtime errors) to fire before we reply, so the
|
|
# Node-side stderr scan in sendCommandWithErrors sees them.
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
if error_msg != "":
|
|
_send_response(peer, {"error": error_msg, "actions_processed": processed})
|
|
else:
|
|
_send_response(peer, {"success": true, "actions_processed": processed})
|
|
|
|
func _inject_key(action: Dictionary) -> String:
|
|
var key_name = action.get("key", "")
|
|
if key_name == "":
|
|
return "key name is required"
|
|
|
|
var keycode = OS.find_keycode_from_string(key_name)
|
|
if keycode == KEY_NONE:
|
|
return "unrecognized key name: '%s'" % key_name
|
|
|
|
var event = InputEventKey.new()
|
|
event.keycode = keycode
|
|
event.physical_keycode = keycode
|
|
event.pressed = action.get("pressed", true)
|
|
event.echo = false
|
|
event.shift_pressed = action.get("shift", false)
|
|
event.ctrl_pressed = action.get("ctrl", false)
|
|
event.alt_pressed = action.get("alt", false)
|
|
# Text-entry Controls (LineEdit, TextEdit) consume `event.unicode`, not just
|
|
# the keycode — without it, typing into a focused LineEdit produces nothing.
|
|
# Auto-derive for ASCII letters and digits; fall back to caller-supplied
|
|
# `unicode` for symbols and non-ASCII.
|
|
if action.has("unicode"):
|
|
event.unicode = int(action.unicode)
|
|
elif keycode >= KEY_A and keycode <= KEY_Z:
|
|
event.unicode = keycode if event.shift_pressed else (keycode + 32)
|
|
elif keycode >= KEY_0 and keycode <= KEY_9:
|
|
event.unicode = keycode
|
|
Input.parse_input_event(event)
|
|
return ""
|
|
|
|
func _resolve_button_name(button_name: String) -> Array:
|
|
match button_name:
|
|
"left":
|
|
return [MOUSE_BUTTON_LEFT, ""]
|
|
"right":
|
|
return [MOUSE_BUTTON_RIGHT, ""]
|
|
"middle":
|
|
return [MOUSE_BUTTON_MIDDLE, ""]
|
|
_:
|
|
return [MOUSE_BUTTON_NONE, "unknown button: '%s' (use 'left', 'right', or 'middle')" % button_name]
|
|
|
|
func _inject_mouse_button(action: Dictionary) -> String:
|
|
var button_result := _resolve_button_name(action.get("button", "left"))
|
|
if button_result[1] != "":
|
|
return button_result[1]
|
|
var button_index: MouseButton = button_result[0]
|
|
|
|
var pos = Vector2(action.get("x", 0), action.get("y", 0))
|
|
var double_click = action.get("double_click", false)
|
|
|
|
# If pressed is explicitly set, only do that one event
|
|
if action.has("pressed"):
|
|
var event = InputEventMouseButton.new()
|
|
event.button_index = button_index
|
|
event.pressed = action.get("pressed")
|
|
event.position = pos
|
|
event.global_position = pos
|
|
event.double_click = double_click
|
|
Input.parse_input_event(event)
|
|
else:
|
|
# Auto press + release (click)
|
|
var press = InputEventMouseButton.new()
|
|
press.button_index = button_index
|
|
press.pressed = true
|
|
press.position = pos
|
|
press.global_position = pos
|
|
press.double_click = double_click
|
|
Input.parse_input_event(press)
|
|
|
|
var release = InputEventMouseButton.new()
|
|
release.button_index = button_index
|
|
release.pressed = false
|
|
release.position = pos
|
|
release.global_position = pos
|
|
Input.parse_input_event(release)
|
|
|
|
return ""
|
|
|
|
func _inject_mouse_motion(action: Dictionary) -> void:
|
|
var event = InputEventMouseMotion.new()
|
|
event.position = Vector2(action.get("x", 0), action.get("y", 0))
|
|
event.global_position = event.position
|
|
event.relative = Vector2(action.get("relative_x", 0), action.get("relative_y", 0))
|
|
Input.parse_input_event(event)
|
|
|
|
func _inject_action(action: Dictionary) -> String:
|
|
var action_name = action.get("action", "")
|
|
if action_name == "":
|
|
return "action name is required"
|
|
|
|
var pressed = action.get("pressed", true)
|
|
var strength = action.get("strength", 1.0)
|
|
|
|
if pressed:
|
|
Input.action_press(action_name, strength)
|
|
else:
|
|
Input.action_release(action_name)
|
|
return ""
|
|
|
|
func _inject_click_element(action: Dictionary) -> String:
|
|
var identifier: String = action.get("element", "")
|
|
if identifier == "":
|
|
return "element identifier is required"
|
|
|
|
var target := _find_control_by_identifier(identifier)
|
|
if target == null:
|
|
return "Could not find UI element: %s" % identifier
|
|
|
|
if not target.is_visible_in_tree():
|
|
return "UI element '%s' is not visible" % identifier
|
|
|
|
var button_result := _resolve_button_name(action.get("button", "left"))
|
|
if button_result[1] != "":
|
|
return button_result[1]
|
|
var button_index: MouseButton = button_result[0]
|
|
var double_click: bool = action.get("double_click", false)
|
|
var rect := target.get_global_rect()
|
|
var center := rect.get_center()
|
|
|
|
var press := InputEventMouseButton.new()
|
|
press.button_index = button_index
|
|
press.pressed = true
|
|
press.position = center
|
|
press.global_position = center
|
|
press.double_click = double_click
|
|
Input.parse_input_event(press)
|
|
|
|
var release := InputEventMouseButton.new()
|
|
release.button_index = button_index
|
|
release.pressed = false
|
|
release.position = center
|
|
release.global_position = center
|
|
Input.parse_input_event(release)
|
|
|
|
return ""
|
|
|
|
# --- UI Element Discovery ---
|
|
|
|
func _handle_get_ui_elements(peer: PeerState, payload: Dictionary) -> void:
|
|
var visible_only: bool = payload.get("visible_only", true)
|
|
var type_filter: String = payload.get("type_filter", "")
|
|
var root := get_tree().root
|
|
var elements: Array[Dictionary] = []
|
|
_collect_control_nodes(root, elements, visible_only, type_filter)
|
|
_send_response(peer, {"elements": elements})
|
|
|
|
func _collect_control_nodes(node: Node, elements: Array[Dictionary], visible_only: bool, type_filter: String = "") -> void:
|
|
if node is Control:
|
|
var ctrl := node as Control
|
|
if visible_only and not ctrl.is_visible_in_tree():
|
|
return
|
|
if type_filter != "" and not ctrl.is_class(type_filter):
|
|
# Still recurse into children even if this node doesn't match
|
|
for child in node.get_children():
|
|
_collect_control_nodes(child, elements, visible_only, type_filter)
|
|
return
|
|
var rect := ctrl.get_global_rect()
|
|
var element := {
|
|
"name": String(ctrl.name),
|
|
"type": ctrl.get_class(),
|
|
"path": str(ctrl.get_path()),
|
|
"rect": {
|
|
"x": rect.position.x,
|
|
"y": rect.position.y,
|
|
"width": rect.size.x,
|
|
"height": rect.size.y,
|
|
},
|
|
"visible": ctrl.is_visible_in_tree(),
|
|
}
|
|
# Extract text content for common Control types
|
|
if ctrl is Button:
|
|
element["text"] = (ctrl as Button).text
|
|
elif ctrl is Label:
|
|
element["text"] = (ctrl as Label).text
|
|
elif ctrl is LineEdit:
|
|
element["text"] = (ctrl as LineEdit).text
|
|
element["placeholder"] = (ctrl as LineEdit).placeholder_text
|
|
elif ctrl is TextEdit:
|
|
element["text"] = (ctrl as TextEdit).text
|
|
elif ctrl is RichTextLabel:
|
|
element["text"] = (ctrl as RichTextLabel).text
|
|
# Disabled state for buttons
|
|
if ctrl is BaseButton:
|
|
element["disabled"] = (ctrl as BaseButton).disabled
|
|
# Tooltip
|
|
if ctrl.tooltip_text != "":
|
|
element["tooltip"] = ctrl.tooltip_text
|
|
elements.append(element)
|
|
for child in node.get_children():
|
|
_collect_control_nodes(child, elements, visible_only, type_filter)
|
|
|
|
func _find_control_by_identifier(identifier: String) -> Control:
|
|
var root := get_tree().root
|
|
# Try as node path first
|
|
if identifier.begins_with("/"):
|
|
var abs_node := root.get_node_or_null(NodePath(identifier))
|
|
if abs_node is Control:
|
|
return abs_node as Control
|
|
# Try as relative path from root
|
|
var node := root.get_node_or_null(NodePath(identifier))
|
|
if node is Control:
|
|
return node as Control
|
|
# BFS: match by node name
|
|
var queue: Array[Node] = []
|
|
queue.append(root)
|
|
while not queue.is_empty():
|
|
var current: Node = queue.pop_front()
|
|
if current is Control:
|
|
if String(current.name) == identifier:
|
|
return current as Control
|
|
for child in current.get_children():
|
|
queue.append(child)
|
|
return null
|
|
|
|
# --- Script Execution ---
|
|
|
|
func _handle_run_script(peer: PeerState, payload: Dictionary) -> void:
|
|
var source: String = payload.get("source", "")
|
|
if source.strip_edges() == "":
|
|
_send_response(peer, {"error": "No script source provided"})
|
|
return
|
|
|
|
# Compile the script at runtime
|
|
var script := GDScript.new()
|
|
script.source_code = source
|
|
var err := script.reload()
|
|
if err != OK:
|
|
_send_response(peer, {"error": "Script compilation failed (error %d). Check syntax." % err})
|
|
return
|
|
|
|
# Instantiate and validate
|
|
var instance = script.new()
|
|
if instance == null:
|
|
_send_response(peer, {"error": "Failed to instantiate script"})
|
|
return
|
|
|
|
if not instance.has_method("execute"):
|
|
if instance is RefCounted:
|
|
instance = null # Let RefCounted free itself
|
|
else:
|
|
instance.free()
|
|
_send_response(peer, {"error": "Script must define func execute(scene_tree: SceneTree) -> Variant"})
|
|
return
|
|
|
|
# Execute (await in case the user's script uses async/await internally)
|
|
var result = await instance.execute(get_tree())
|
|
|
|
# Clean up
|
|
if instance is RefCounted:
|
|
instance = null
|
|
else:
|
|
instance.free()
|
|
|
|
# Serialize and respond
|
|
var serialized = _serialize_value(result)
|
|
_send_response(peer, {"success": true, "result": serialized})
|
|
|
|
func _serialize_value(value: Variant) -> Variant:
|
|
if value == null:
|
|
return null
|
|
|
|
match typeof(value):
|
|
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
|
|
return value
|
|
TYPE_VECTOR2:
|
|
var v: Vector2 = value
|
|
return {"x": v.x, "y": v.y}
|
|
TYPE_VECTOR2I:
|
|
var v: Vector2i = value
|
|
return {"x": v.x, "y": v.y}
|
|
TYPE_VECTOR3:
|
|
var v: Vector3 = value
|
|
return {"x": v.x, "y": v.y, "z": v.z}
|
|
TYPE_VECTOR3I:
|
|
var v: Vector3i = value
|
|
return {"x": v.x, "y": v.y, "z": v.z}
|
|
TYPE_COLOR:
|
|
var c: Color = value
|
|
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a}
|
|
TYPE_DICTIONARY:
|
|
var d: Dictionary = value
|
|
var result := {}
|
|
for key in d:
|
|
result[str(key)] = _serialize_value(d[key])
|
|
return result
|
|
TYPE_ARRAY:
|
|
var a: Array = value
|
|
var result := []
|
|
for item in a:
|
|
result.append(_serialize_value(item))
|
|
return result
|
|
TYPE_OBJECT:
|
|
if value is Node:
|
|
var node: Node = value
|
|
return {"class": node.get_class(), "name": String(node.name), "path": str(node.get_path())}
|
|
elif value is Resource:
|
|
var res: Resource = value
|
|
return {"class": res.get_class(), "path": res.resource_path}
|
|
else:
|
|
return str(value)
|
|
_:
|
|
return str(value)
|
|
|
|
# --- Shutdown ---
|
|
|
|
func _handle_shutdown(peer: PeerState) -> void:
|
|
_shutting_down = true
|
|
_send_response(peer, {"status": "shutting_down"})
|
|
# Let the response flush before we tear the listener down. A new command
|
|
# arriving in this 2-frame window would dispatch against a peer that's
|
|
# about to close; the response write fails gracefully and the Node side
|
|
# sees BridgeDisconnectedError. MCP serializes calls so this is theoretical.
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
_close_all_peers()
|
|
if tcp_server != null:
|
|
tcp_server.stop()
|
|
# Detach from the tree so subsequent _process ticks don't run.
|
|
queue_free()
|
|
|
|
# --- Utility ---
|
|
|
|
func _send_response(peer: PeerState, data: Dictionary) -> void:
|
|
var resp := JSON.stringify(data)
|
|
var body := resp.to_utf8_buffer()
|
|
if body.size() > MAX_FRAME_BYTES:
|
|
push_error("McpBridge: Response exceeds %d bytes; dropping" % MAX_FRAME_BYTES)
|
|
peer.handling = false
|
|
return
|
|
if peer.stream != null and peer.stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
var header := PackedByteArray()
|
|
header.resize(FRAME_HEADER_BYTES)
|
|
var size := body.size()
|
|
header[0] = (size >> 24) & 0xFF
|
|
header[1] = (size >> 16) & 0xFF
|
|
header[2] = (size >> 8) & 0xFF
|
|
header[3] = size & 0xFF
|
|
peer.stream.put_data(header)
|
|
peer.stream.put_data(body)
|
|
peer.handling = false
|
|
|
|
func _close_all_peers() -> void:
|
|
for peer in _peers:
|
|
if peer.stream != null:
|
|
peer.stream.disconnect_from_host()
|
|
peer.stream = null
|
|
_peers.clear()
|
|
|
|
func _exit_tree() -> void:
|
|
if not _shutting_down:
|
|
push_warning("McpBridge: removed from tree without shutdown — bridge connection will be lost")
|
|
_close_all_peers()
|
|
if tcp_server != null:
|
|
tcp_server.stop()
|
|
tcp_server = null
|
|
print("McpBridge: Stopped")
|