breakpoint-mcp 1.14.1 → 1.19.1
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 +2 -2
- package/addon/breakpoint_mcp/bridge_secret.gd +61 -0
- package/addon/breakpoint_mcp/bridge_server.gd +66 -4
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/pause_latch.gd +65 -0
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/plugin.gd +6 -0
- package/addon/breakpoint_mcp/runtime_bridge.gd +333 -4
- package/addon/breakpoint_mcp/status_dock.gd +48 -0
- package/dist/bridge.js +22 -1
- package/dist/capabilities.js +150 -0
- package/dist/cli/clients.js +5 -2
- package/dist/cli/doctor.js +41 -0
- package/dist/cli/init.js +48 -6
- package/dist/config.js +64 -0
- package/dist/confirm.js +21 -0
- package/dist/index.js +72 -65
- package/dist/pause.js +105 -0
- package/dist/recipes.js +168 -0
- package/dist/schemas.js +43 -0
- package/dist/secret.js +38 -0
- package/dist/tools/runtime.js +112 -0
- package/dist/toolsets.js +44 -0
- package/package.json +1 -1
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. **
|
|
7
|
-
+
|
|
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
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|