breakpoint-mcp 1.14.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  The MCP **host** for [Breakpoint MCP](https://github.com/jlivingston-Cipher/godot-breakpoint-mcp) —
4
4
  a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
5
5
  Godot game engine to AI coding assistants across four planes: headless CLI, the live
6
- editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **271 tools
7
- + 5 MCP resources**, built against the stable `@modelcontextprotocol/sdk` 1.x API.
6
+ editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **276 tools
7
+ + 6 MCP resources**, built against the stable `@modelcontextprotocol/sdk` 1.x API.
8
8
  Developed and tested with **Claude**; because MCP is an open protocol, other clients can
9
9
  connect too (currently untested — reports welcome).
10
10
 
@@ -0,0 +1,61 @@
1
+ @tool
2
+ extends RefCounted
3
+ ## Shared loopback-bridge auth: a per-project secret + a constant-time compare.
4
+ ##
5
+ ## The secret is a 64-char hex string minted once per project and stored at
6
+ ## res://.godot/breakpoint_mcp.secret. The .godot dir is engine-managed and, by
7
+ ## Godot convention, git-ignored — so the secret never lands in version control.
8
+ ## Both bridges in a project (the editor bridge_server and the runtime autoload)
9
+ ## read the SAME file, and the Node host reads it too, from
10
+ ## <projectPath>/.godot/breakpoint_mcp.secret — so all three agree with ZERO user
11
+ ## configuration. Host-side an env override (BREAKPOINT_BRIDGE_SECRET /
12
+ ## BREAKPOINT_RUNTIME_SECRET) wins, for advanced / host-launched-child cases.
13
+ ##
14
+ ## Why: the loopback bind (127.0.0.1) is the only access control otherwise, so any
15
+ ## OTHER local process on a shared machine could drive the bridge — including the
16
+ ## destructive editor ops that are elicitation-gated in the HOST. A direct socket
17
+ ## bypasses that host gate; requiring this handshake moves the gate's teeth to the
18
+ ## addon. Defense-in-depth, not a remote-RCE fix (loopback already blocks the net).
19
+
20
+ const SECRET_PATH := "res://.godot/breakpoint_mcp.secret"
21
+
22
+
23
+ ## Return the shared secret, minting + persisting a fresh one if none exists.
24
+ ## Returns "" ONLY on an unrecoverable IO error — the caller then runs WITHOUT
25
+ ## auth (logging a warning) rather than bricking the bridge.
26
+ static func load_or_mint() -> String:
27
+ if FileAccess.file_exists(SECRET_PATH):
28
+ var rf := FileAccess.open(SECRET_PATH, FileAccess.READ)
29
+ if rf != null:
30
+ var existing := rf.get_as_text().strip_edges()
31
+ rf = null
32
+ if existing.length() > 0:
33
+ return existing
34
+ # Mint 32 cryptographically-random bytes -> 64 hex chars.
35
+ var crypto := Crypto.new()
36
+ var hex := crypto.generate_random_bytes(32).hex_encode()
37
+ var godot_dir := ProjectSettings.globalize_path("res://.godot")
38
+ if not DirAccess.dir_exists_absolute(godot_dir):
39
+ DirAccess.make_dir_recursive_absolute(godot_dir)
40
+ var wf := FileAccess.open(SECRET_PATH, FileAccess.WRITE)
41
+ if wf == null:
42
+ return ""
43
+ wf.store_string(hex)
44
+ wf = null
45
+ return hex
46
+
47
+
48
+ ## Constant-time equality over the UTF-8 bytes of two strings. Folds any length
49
+ ## difference into the result and always scans the longer input, so it never
50
+ ## short-circuits on the first differing byte (no timing side channel on the
51
+ ## secret's content). The secret's length is fixed (64) and not itself sensitive.
52
+ static func const_time_eq(a: String, b: String) -> bool:
53
+ var ab := a.to_utf8_buffer()
54
+ var bb := b.to_utf8_buffer()
55
+ var diff: int = ab.size() ^ bb.size()
56
+ var n: int = ab.size() if ab.size() > bb.size() else bb.size()
57
+ for i in n:
58
+ var x: int = ab[i] if i < ab.size() else 0
59
+ var y: int = bb[i] if i < bb.size() else 0
60
+ diff |= x ^ y
61
+ return diff == 0
@@ -13,6 +13,8 @@ extends Node
13
13
 
14
14
  const Operations := preload("res://addons/breakpoint_mcp/operations.gd")
15
15
  const DEFAULT_PORT := 9080
16
+ const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
17
+ const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
16
18
 
17
19
  var _server: TCPServer
18
20
  var _ops: Operations
@@ -23,6 +25,11 @@ var _port: int = DEFAULT_PORT
23
25
  ## triggers a filesystem rescan/reimport) cannot re-enter `_process` and re-drain
24
26
  ## the same still-buffered line, which would recurse until the stack overflows.
25
27
  var _dispatching: bool = false
28
+ ## Loopback-auth handshake state (default-on; see bridge_secret.gd). `_secret` is
29
+ ## the shared per-project secret; `_auth_required` is false only when auth is
30
+ ## explicitly disabled (BREAKPOINT_BRIDGE_INSECURE) or the secret can't be minted.
31
+ var _secret: String = ""
32
+ var _auth_required: bool = false
26
33
 
27
34
 
28
35
  func setup(plugin: EditorPlugin) -> void:
@@ -34,6 +41,7 @@ func _ready() -> void:
34
41
  var env_port := OS.get_environment("BREAKPOINT_BRIDGE_PORT")
35
42
  if env_port != "" and env_port.is_valid_int():
36
43
  _port = int(env_port)
44
+ _setup_auth()
37
45
  _server = TCPServer.new()
38
46
  var err := _server.listen(_port, "127.0.0.1")
39
47
  if err != OK:
@@ -42,6 +50,22 @@ func _ready() -> void:
42
50
  print("[breakpoint_mcp] listening on 127.0.0.1:%d" % _port)
43
51
 
44
52
 
53
+ ## Establish the loopback-auth secret unless explicitly disabled. Default-on:
54
+ ## BREAKPOINT_BRIDGE_INSECURE=1 (or =true) turns auth OFF (documented escape
55
+ ## hatch, not recommended). If the secret can't be persisted, run WITHOUT auth
56
+ ## rather than bricking the bridge (a broken mint must not lock out the host).
57
+ func _setup_auth() -> void:
58
+ var insecure := OS.get_environment("BREAKPOINT_BRIDGE_INSECURE").to_lower()
59
+ if insecure == "1" or insecure == "true":
60
+ _auth_required = false
61
+ push_warning("[breakpoint_mcp] BREAKPOINT_BRIDGE_INSECURE set — loopback bridge auth DISABLED")
62
+ return
63
+ _secret = BridgeSecret.load_or_mint()
64
+ _auth_required = _secret != ""
65
+ if not _auth_required:
66
+ push_error("[breakpoint_mcp] could not establish bridge secret — running WITHOUT auth")
67
+
68
+
45
69
  func shutdown() -> void:
46
70
  for c in _clients:
47
71
  var peer: StreamPeerTCP = c["peer"]
@@ -83,7 +107,7 @@ func _process(_delta: float) -> void:
83
107
  while _server.is_connection_available():
84
108
  var peer := _server.take_connection()
85
109
  if peer:
86
- _clients.append({"peer": peer, "buf": ""})
110
+ _clients.append({"peer": peer, "buf": "", "authed": not _auth_required})
87
111
  # Service existing connections.
88
112
  var still_alive: Array = []
89
113
  for c in _clients:
@@ -99,6 +123,10 @@ func _process(_delta: float) -> void:
99
123
  var bytes: PackedByteArray = chunk[1]
100
124
  c["buf"] += bytes.get_string_from_utf8()
101
125
  _drain_lines(c)
126
+ if c.get("close", false):
127
+ peer.poll()
128
+ peer.disconnect_from_host()
129
+ continue
102
130
  still_alive.append(c)
103
131
  _clients = still_alive
104
132
  _dispatching = false
@@ -114,19 +142,45 @@ func _drain_lines(c: Dictionary) -> void:
114
142
  buf = buf.substr(nl + 1)
115
143
  line = line.strip_edges()
116
144
  if line != "":
117
- _handle_line(c["peer"], line)
145
+ _handle_line(c, line)
146
+ if c.get("close", false):
147
+ break
118
148
  c["buf"] = buf
119
149
 
120
150
 
121
- func _handle_line(peer: StreamPeerTCP, line: String) -> void:
151
+ func _handle_line(c: Dictionary, line: String) -> void:
152
+ var peer: StreamPeerTCP = c["peer"]
122
153
  var parsed: Variant = JSON.parse_string(line)
123
154
  if typeof(parsed) != TYPE_DICTIONARY:
155
+ # Pre-auth we don't even echo a parse error (no-echo discipline) — deny + close.
156
+ if not c.get("authed", false):
157
+ _deny_unauth(c)
158
+ return
124
159
  _send(peer, {"id": null, "ok": false, "error": {"code": "bad_json", "message": "Could not parse request line"}})
125
160
  return
126
161
  var req: Dictionary = parsed
127
162
  var id: Variant = req.get("id", null)
128
163
  var method := String(req.get("method", ""))
129
164
  var params: Dictionary = req.get("params", {}) if typeof(req.get("params")) == TYPE_DICTIONARY else {}
165
+ # Handshake gate: an unauthenticated peer may ONLY authenticate. Anything else
166
+ # is denied with a generic code and the connection is closed.
167
+ if not c.get("authed", false):
168
+ if method == "auth" and BridgeSecret.const_time_eq(String(params.get("secret", "")), _secret):
169
+ c["authed"] = true
170
+ _send(peer, {"id": id, "ok": true})
171
+ else:
172
+ _deny_unauth(c)
173
+ return
174
+ # Pause latch (addon "Pause Agent" control): while a human has the agent paused
175
+ # from the editor dock, HOLD every new command except a liveness `ping` — reject
176
+ # without dispatching, so the host re-issues after resume. An op already running
177
+ # finishes (one line per tick). This is the coarse editor+runtime-plane stop,
178
+ # distinct from the host signal latch's finer, whole-surface, mutating-only hold.
179
+ if method != "ping" and PauseLatch.is_paused():
180
+ var held := {"id": id}
181
+ held.merge(PauseLatch.held_response(method))
182
+ _send(peer, held)
183
+ return
130
184
  var result: Dictionary
131
185
  # Handlers never throw in normal operation; guard anyway.
132
186
  result = _ops.dispatch(method, params)
@@ -135,6 +189,14 @@ func _handle_line(peer: StreamPeerTCP, line: String) -> void:
135
189
  _send(peer, response)
136
190
 
137
191
 
192
+ ## Generic, no-echo denial for an unauthenticated peer; marks the connection for
193
+ ## closing. Never reveals the expected secret, the received value, the failing
194
+ ## method, or any engine detail — only a fixed `unauthorized` code.
195
+ func _deny_unauth(c: Dictionary) -> void:
196
+ _send(c["peer"], {"id": null, "ok": false, "error": {"code": "unauthorized"}})
197
+ c["close"] = true
198
+
199
+
138
200
  func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
139
201
  var text := JSON.stringify(obj) + "\n"
140
202
  peer.put_data(text.to_utf8_buffer())
@@ -146,5 +208,5 @@ func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
146
208
  func broadcast_event(uri: String) -> void:
147
209
  for c in _clients:
148
210
  var peer: StreamPeerTCP = c["peer"]
149
- if peer and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
211
+ if peer and c.get("authed", false) and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
150
212
  _send(peer, {"event": "resource.changed", "uri": uri})
@@ -7,7 +7,7 @@ extends RefCounted
7
7
  ## wrapped in the EditorUndoRedoManager so a human can Ctrl-Z anything the assistant did.
8
8
 
9
9
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
10
- const ADDON_VERSION := "1.4.3"
10
+ const ADDON_VERSION := "1.7.0"
11
11
 
12
12
  var _plugin: EditorPlugin
13
13
 
@@ -0,0 +1,65 @@
1
+ @tool
2
+ extends RefCounted
3
+ ## Shared "Pause Agent" latch — the addon's human-facing control surface.
4
+ ##
5
+ ## The in-editor status dock's "Pause Agent" toggle engages this latch; both the
6
+ ## editor bridge (bridge_server.gd) and the runtime bridge (runtime_bridge.gd)
7
+ ## honor it. While paused they REJECT new agent commands on the editor + runtime
8
+ ## planes; an op already running finishes, and a bare `ping` still answers so the
9
+ ## host can see the bridge is alive. It is the visible companion to the host-side
10
+ ## signal latch (SIGUSR1/SIGUSR2): the host latch is the finer instrument (it
11
+ ## holds only MUTATING actions, across the WHOLE surface); this addon latch is the
12
+ ## coarse, one-click stop scoped to the two engine-facing bridges.
13
+ ##
14
+ ## Cross-process by design. The toggle lives in the EDITOR while the runtime bridge
15
+ ## lives in the RUNNING GAME (a separate OS process), so an in-memory flag can't be
16
+ ## shared. State is a flag FILE in the engine-managed, git-ignored res://.godot/ —
17
+ ## the same directory and the same reason bridge_secret.gd uses one: it is the one
18
+ ## place both the editor and an editor-launched game can reach. File present =
19
+ ## paused. Only the editor writes it; both bridges read it.
20
+ ##
21
+ ## NOT an emergency stop: it holds ENTRY to a new command and never interrupts an
22
+ ## in-flight op, and (unlike the queuing host latch) it does not hold the caller —
23
+ ## it rejects, so the host re-issues after resume.
24
+
25
+ const FLAG_PATH := "res://.godot/breakpoint_mcp.pause"
26
+
27
+
28
+ ## True while the agent is paused (the flag file exists). One stat per call —
29
+ ## cheap enough for the dispatch seam — and always reflects the latest state
30
+ ## written by the editor, even from the separate game process.
31
+ static func is_paused() -> bool:
32
+ return FileAccess.file_exists(FLAG_PATH)
33
+
34
+
35
+ ## Engage (paused=true) or clear (paused=false) the latch. Engaging writes a tiny
36
+ ## JSON marker (for legibility — presence is what the bridges read); clearing
37
+ ## removes the file. Best-effort: a failed write/remove logs a warning rather than
38
+ ## throwing, so the toggle can never brick the editor (mirrors bridge_secret's
39
+ ## mint-or-warn posture). Called only in the editor process, where res:// is
40
+ ## writable; the runtime bridge only ever reads via is_paused().
41
+ static func set_paused(paused: bool) -> void:
42
+ if paused:
43
+ var f := FileAccess.open(FLAG_PATH, FileAccess.WRITE)
44
+ if f == null:
45
+ push_warning("[breakpoint_mcp] could not write pause flag %s" % FLAG_PATH)
46
+ return
47
+ f.store_string(JSON.stringify({"paused": true, "at": int(Time.get_unix_time_from_system())}))
48
+ f.close()
49
+ elif FileAccess.file_exists(FLAG_PATH):
50
+ var err := DirAccess.remove_absolute(ProjectSettings.globalize_path(FLAG_PATH))
51
+ if err != OK:
52
+ push_warning("[breakpoint_mcp] could not clear pause flag %s" % FLAG_PATH)
53
+
54
+
55
+ ## The standard "held" response a bridge returns for a command received while
56
+ ## paused — no dispatch happened. Mirrors the host confirm.ts blocking wording so
57
+ ## the agent gets a clear, actionable reason instead of a silent drop.
58
+ static func held_response(method: String) -> Dictionary:
59
+ return {
60
+ "ok": false,
61
+ "error": {
62
+ "code": "paused",
63
+ "message": "Agent paused from the Breakpoint editor dock — \"%s\" was held and NOT executed. Resume from the dock, then re-issue." % method,
64
+ },
65
+ }
@@ -3,5 +3,5 @@
3
3
  name="Breakpoint MCP"
4
4
  description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
5
5
  author="James Livingston"
6
- version="1.4.3"
6
+ version="1.7.0"
7
7
  script="plugin.gd"
@@ -13,6 +13,7 @@ extends EditorPlugin
13
13
 
14
14
  const BridgeServer := preload("res://addons/breakpoint_mcp/bridge_server.gd")
15
15
  const StatusDock := preload("res://addons/breakpoint_mcp/status_dock.gd")
16
+ const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
16
17
  const RUNTIME_AUTOLOAD := "BreakpointRuntimeBridge"
17
18
  const RUNTIME_SCRIPT := "res://addons/breakpoint_mcp/runtime_bridge.gd"
18
19
 
@@ -43,6 +44,9 @@ func _enter_tree() -> void:
43
44
  _selection.selection_changed.connect(_on_selection_changed)
44
45
  if not scene_changed.is_connected(_on_scene_changed):
45
46
  scene_changed.connect(_on_scene_changed)
47
+ # Pause is a session control: clear any flag left over from a prior session so a
48
+ # fresh editor always starts running (never silently paused by a stale file).
49
+ PauseLatch.set_paused(false)
46
50
  print("[breakpoint_mcp] plugin enabled")
47
51
 
48
52
 
@@ -61,6 +65,8 @@ func _exit_tree() -> void:
61
65
  _server.shutdown()
62
66
  _server.queue_free()
63
67
  _server = null
68
+ # Don't leave the agent latched paused once the plugin (and its dock) are gone.
69
+ PauseLatch.set_paused(false)
64
70
  print("[breakpoint_mcp] plugin disabled")
65
71
 
66
72