Skip to content

Commit efd15f0

Browse files
MCP: Audit and harden native MCP server implementation (#1290)
* MCP: Audit and harden native MCP server implementation Fixes several bugs, improves spec compliance, and fills in missing functionality across the MCP module. Bugfixes: - Fix null-deref crash in capture action when viewport returns no image or PNG encoding fails (mcp_bridge.cpp) - Fix double-wrapped JSON-RPC error responses: pre-init tools/* requests now return a proper top-level error via process_string override instead of being nested inside a result envelope (mcp_protocol.{h,cpp}) - Fix double-wrapping on INVALID_PARAMS paths in tools/call handler Protocol compliance: - Update protocol version from 2024-11-05 to 2025-06-18 - Add version negotiation: echo client version if supported, else return latest (mcp_protocol.cpp) - Move initialized flag from initialize response to notifications/initialized per spec lifecycle requirements - Declare tools.listChanged capability explicitly Security and robustness: - Harden validate_path: segment-based .. check instead of substring match, applied consistently to all path-accepting actions (instance_path, resource paths, code_intel paths were previously unvalidated) - Fix bridge concurrency: update() no longer drops an active connection mid-command when a new peer connects - Improve send_command: chunked reads via CharString accumulation with single-pass UTF-8 decode to handle multi-byte chars across TCP boundaries New functionality: - Implement code_intel search action (recursive .gd file content search) - Implement project_config add_input action (writes InputEventKey to project.godot with runtime InputMap registration) - Implement project_config add_autoload action (writes autoload entry to project.godot) Quality improvements: - Add MCP tool annotations (readOnlyHint, destructiveHint, etc.) to all 5 tools per 2025-06-18 spec - Clarify project_config schema descriptions (which params each action uses) to prevent agent confusion - Improve _ensure_callback_exists: word-boundary detection handles static func and annotation prefixes - Add signal-exists validation before connect() in scene_action - Remove dead code (_make_text_content) - Fill in class reference documentation for MCPServer, MCPBridge, MCPProtocol AI tools were used in the development of this contribution, in accordance with the project AI Policy. All changes have been manually tested end-to-end with a live MCP handshake across all 5 tools. * MCP: Validate autoload files and refresh input actions Reject nonexistent autoload paths before modifying project settings. When an input action already exists, replace its live deadzone and event bindings so its runtime state matches the persisted project configuration.
1 parent bf5543c commit efd15f0

8 files changed

Lines changed: 389 additions & 60 deletions

File tree

modules/mcp/doc_classes/MCPBridge.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<?xml version="1.0" encoding="UTF-8" ?>
22
<class name="MCPBridge" inherits="Object" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../doc/class.xsd">
33
<brief_description>
4+
TCP bridge relaying commands between the MCP server and a running game process.
45
</brief_description>
56
<description>
7+
[MCPBridge] is a bidirectional TCP relay. On the server (headless) side it listens on an ephemeral loopback port and sends commands produced by MCP tools. On the game side (started with the [code]--mcp-bridge-port[/code] flag) it connects back to that port, receives commands, executes them against the live scene tree/viewport, and returns results. The bridge is single-connection and automatically replaces a stale peer when a new game process connects.
68
</description>
79
<tutorials>
810
</tutorials>
911
<methods>
1012
<method name="update">
1113
<return type="void" />
1214
<description>
15+
Pumps the bridge: on the host side it accepts pending connections; on the game side it reads incoming commands, dispatches them, and writes responses. Called each frame from the main loop (game side) or the server's bridge thread (host side).
1316
</description>
1417
</method>
1518
</methods>

modules/mcp/doc_classes/MCPProtocol.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<?xml version="1.0" encoding="UTF-8" ?>
22
<class name="MCPProtocol" inherits="JSONRPC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../doc/class.xsd">
33
<brief_description>
4+
MCP JSON-RPC protocol handler: capability negotiation, tool listing, and dispatch.
45
</brief_description>
56
<description>
7+
Implements the Model Context Protocol request layer on top of the JSONRPC class. Handles the [code]initialize[/code] handshake with version negotiation, the [code]notifications/initialized[/code] lifecycle event, [code]ping[/code], [code]tools/list[/code], and [code]tools/call[/code]. Tool execution is delegated to the internal MCPTools class. The operation phase is gated until the [code]initialized[/code] notification is received, per spec.
68
</description>
79
<tutorials>
810
</tutorials>
911
<methods>
1012
<method name="is_initialized" qualifiers="const">
1113
<return type="bool" />
1214
<description>
15+
Returns [code]true[/code] after the client has sent the [code]notifications/initialized[/code] handshake message, marking the start of the operation phase.
1316
</description>
1417
</method>
1518
</methods>

modules/mcp/doc_classes/MCPServer.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
<?xml version="1.0" encoding="UTF-8" ?>
22
<class name="MCPServer" inherits="Object" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../doc/class.xsd">
33
<brief_description>
4+
Stdio-based MCP (Model Context Protocol) server for AI agent integration.
45
</brief_description>
56
<description>
7+
The MCP server exposes the running Redot project to AI coding assistants over a JSON-RPC stdio transport. It is started with the [code]--mcp-server[/code] command-line flag, which implies headless mode. The server reads newline-delimited JSON-RPC requests on stdin and writes responses to stdout. A TCP bridge ([MCPBridge]) is used to forward tool calls (screenshots, input, tree inspection) to a separately launched game process. See [url=https://modelcontextprotocol.io/]the MCP specification[/url] for protocol details.
68
</description>
79
<tutorials>
810
</tutorials>
911
<methods>
1012
<method name="is_running" qualifiers="const">
1113
<return type="bool" />
1214
<description>
15+
Returns [code]true[/code] while the server loop is processing requests.
1316
</description>
1417
</method>
1518
<method name="start">
1619
<return type="void" />
1720
<description>
21+
Starts the blocking server loop. Reads requests from stdin and dispatches them to the registered protocol handlers. Returns when the input stream is closed or [method stop] is requested.
1822
</description>
1923
</method>
2024
<method name="stop">
2125
<return type="void" />
2226
<description>
27+
Signals the server loop to stop and wakes the stdin poll.
2328
</description>
2429
</method>
2530
</methods>

modules/mcp/mcp_bridge.cpp

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,31 +133,57 @@ Dictionary MCPBridge::send_command(const String &p_action, const Dictionary &p_a
133133

134134
// Wait for response (blocking with timeout)
135135
uint64_t start_time = OS::get_singleton()->get_ticks_msec();
136-
String response_str;
136+
CharString raw_buffer;
137+
bool got_response = false;
137138
while (OS::get_singleton()->get_ticks_msec() - start_time < 5000) {
138139
conn->poll();
139-
if (conn->get_available_bytes() > 0) {
140-
uint8_t c;
141-
int read_bytes;
142-
conn->get_partial_data(&c, 1, read_bytes);
140+
int avail = conn->get_available_bytes();
141+
if (avail > 0) {
142+
uint8_t buf[4096];
143+
int to_read = avail < (int)sizeof(buf) ? avail : (int)sizeof(buf);
144+
int read_bytes = 0;
145+
conn->get_partial_data(buf, to_read, read_bytes);
143146
if (read_bytes > 0) {
144-
if (c == '\n') {
145-
goto parsed;
147+
// Append raw bytes to the buffer (resize + copy).
148+
int old_len = raw_buffer.length();
149+
raw_buffer.resize_uninitialized(old_len + read_bytes + 1);
150+
char *dst = raw_buffer.ptrw();
151+
memcpy(dst + old_len, buf, read_bytes);
152+
dst[old_len + read_bytes] = 0;
153+
154+
// Only scan the newly appended bytes for the delimiter.
155+
for (int i = old_len; i < old_len + read_bytes; i++) {
156+
if (raw_buffer[i] == '\n') {
157+
got_response = true;
158+
break;
159+
}
160+
}
161+
if (got_response) {
162+
break;
146163
}
147-
response_str += (char)c;
148164
}
149165
} else {
150166
OS::get_singleton()->delay_usec(1000);
151167
}
152168
}
153169

154-
{
170+
if (!got_response) {
155171
Dictionary err;
156172
err["error"] = "Bridge timeout";
157173
return err;
158174
}
159175

160-
parsed:
176+
// Decode the full buffer at once so multi-byte UTF-8 sequences
177+
// spanning TCP chunk boundaries are handled correctly.
178+
int nl = -1;
179+
for (int i = 0; i < raw_buffer.length(); i++) {
180+
if (raw_buffer[i] == '\n') {
181+
nl = i;
182+
break;
183+
}
184+
}
185+
int len = (nl >= 0) ? nl : raw_buffer.length();
186+
String response_str = String::utf8(raw_buffer.get_data(), len);
161187
if (response_str.is_empty()) {
162188
Dictionary err;
163189
err["error"] = "Empty response";
@@ -178,12 +204,22 @@ void MCPBridge::update() {
178204
MutexLock lock(mutex);
179205
if (is_host) {
180206
if (server->is_connection_available()) {
207+
// Only replace the connection if the current one is stale/disconnected,
208+
// so we never drop a peer mid-command from the bridge thread.
209+
bool current_ok = false;
181210
if (connection.is_valid()) {
182-
fprintf(stderr, "[MCP] Dropping existing connection for new client\n");
183-
connection->disconnect_from_host();
211+
connection->poll();
212+
current_ok = (connection->get_status() == StreamPeerTCP::STATUS_CONNECTED);
213+
}
214+
if (!current_ok) {
215+
connection = server->take_connection();
216+
fprintf(stderr, "[MCP] Game process connected to bridge on host side\n");
217+
} else {
218+
// Drain and discard the extra pending connection.
219+
Ref<StreamPeerTCP> extra = server->take_connection();
220+
extra->disconnect_from_host();
221+
fprintf(stderr, "[MCP] Bridge: extra pending connection discarded (active connection in use)\n");
184222
}
185-
connection = server->take_connection();
186-
fprintf(stderr, "[MCP] Game process connected to bridge on host side\n");
187223
}
188224
} else {
189225
// Client (Game) side
@@ -289,13 +325,21 @@ Dictionary MCPBridge::_process_command(const Dictionary &p_cmd) {
289325
return resp;
290326
}
291327
Ref<Image> img = tex->get_image();
328+
if (img.is_null()) {
329+
resp["error"] = "Viewport returned no image (not rendered yet or headless)";
330+
return resp;
331+
}
292332

293333
float scale = args.get("scale", 1.0);
294334
if (scale != 1.0) {
295335
img->resize(img->get_width() * scale, img->get_height() * scale);
296336
}
297337

298338
PackedByteArray png_buffer = img->save_png_to_buffer();
339+
if (png_buffer.is_empty()) {
340+
resp["error"] = "Failed to encode PNG";
341+
return resp;
342+
}
299343
resp["image_base64"] = CryptoCore::b64_encode_str(png_buffer.ptr(), png_buffer.size());
300344
resp["format"] = "png";
301345
resp["width"] = img->get_width();

modules/mcp/mcp_protocol.cpp

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ void MCPProtocol::_bind_methods() {
7272

7373
Dictionary MCPProtocol::_make_capabilities() const {
7474
Dictionary caps;
75-
// We support tools
7675
Dictionary tools_cap;
76+
tools_cap["listChanged"] = false;
7777
caps["tools"] = tools_cap;
7878
return caps;
7979
}
@@ -85,13 +85,27 @@ Dictionary MCPProtocol::_make_server_info() const {
8585
return info;
8686
}
8787

88+
String MCPProtocol::_negotiate_version(const String &p_client_version) {
89+
if (p_client_version == MCP_PROTOCOL_VERSION_LATEST || p_client_version == MCP_PROTOCOL_VERSION_LEGACY) {
90+
return p_client_version;
91+
}
92+
return MCP_PROTOCOL_VERSION_LATEST;
93+
}
94+
8895
Variant MCPProtocol::_handle_initialize(const Variant &p_params) {
96+
String client_version;
97+
if (p_params.get_type() == Variant::DICTIONARY) {
98+
Dictionary params = p_params;
99+
Variant pv = params.get("protocolVersion", Variant());
100+
if (pv.get_type() == Variant::STRING) {
101+
client_version = pv;
102+
}
103+
}
104+
89105
Dictionary result;
90-
result["protocolVersion"] = MCP_PROTOCOL_VERSION;
106+
result["protocolVersion"] = _negotiate_version(client_version);
91107
result["capabilities"] = _make_capabilities();
92108
result["serverInfo"] = _make_server_info();
93-
94-
initialized = true;
95109
return result;
96110
}
97111

@@ -100,17 +114,22 @@ Array MCPProtocol::_get_tool_definitions() const {
100114
}
101115

102116
Variant MCPProtocol::_handle_tools_list(const Variant &p_params) {
103-
if (!is_initialized()) {
104-
return make_response_error(INVALID_REQUEST, "Protocol not initialized");
117+
// Defense-in-depth: process_string gates this, but guard anyway in case
118+
// dispatch bypasses the override (e.g. via base pointer or a notification).
119+
if (!initialized) {
120+
Dictionary result;
121+
result["tools"] = Array();
122+
return result;
105123
}
106124
Dictionary result;
107125
result["tools"] = _get_tool_definitions();
108126
return result;
109127
}
110128

111129
Variant MCPProtocol::_handle_tools_call(const Variant &p_params) {
112-
if (!is_initialized()) {
113-
return make_response_error(INVALID_REQUEST, "Protocol not initialized");
130+
// Defense-in-depth: never execute a tool before the init handshake.
131+
if (!initialized) {
132+
return _make_tool_result_error("Protocol not initialized");
114133
}
115134
Dictionary params;
116135
if (p_params.get_type() == Variant::DICTIONARY) {
@@ -120,15 +139,15 @@ Variant MCPProtocol::_handle_tools_call(const Variant &p_params) {
120139
if (!arr.is_empty() && arr[0].get_type() == Variant::DICTIONARY) {
121140
params = arr[0];
122141
} else {
123-
return make_response_error(INVALID_PARAMS, "Tool call parameters must be an object (or array with object)");
142+
return _make_tool_result_error("Tool call parameters must be an object (or array with object)");
124143
}
125144
} else {
126-
return make_response_error(INVALID_PARAMS, "Tool call parameters must be an object");
145+
return _make_tool_result_error("Tool call parameters must be an object");
127146
}
128147

129148
String tool_name = params.get("name", "");
130149
if (tool_name.is_empty()) {
131-
return make_response_error(INVALID_PARAMS, "Missing tool name");
150+
return _make_tool_result_error("Missing tool name");
132151
}
133152

134153
Dictionary arguments;
@@ -154,18 +173,55 @@ Dictionary MCPProtocol::_make_tool_result(const Array &p_content, bool p_is_erro
154173
return result;
155174
}
156175

157-
Dictionary MCPProtocol::_make_text_content(const String &p_text) const {
176+
Dictionary MCPProtocol::_make_tool_result_error(const String &p_message) const {
158177
Dictionary content;
159178
content["type"] = "text";
160-
content["text"] = p_text;
161-
return content;
179+
content["text"] = "Error: " + p_message;
180+
181+
Array content_arr;
182+
content_arr.push_back(content);
183+
184+
Dictionary result;
185+
result["content"] = content_arr;
186+
result["isError"] = true;
187+
return result;
162188
}
163189

164190
Variant MCPProtocol::_handle_initialized_notification(const Variant &p_params) {
165-
// Notification, no return value needed (but Variant() is void)
191+
// Per spec, the operation phase begins after this notification.
192+
initialized = true;
166193
return Variant();
167194
}
168195

169196
Variant MCPProtocol::_handle_ping(const Variant &p_params) {
170197
return Dictionary(); // Empty object
171198
}
199+
200+
String MCPProtocol::process_string(const String &p_input) {
201+
// Gate tools/* requests before initialization at the protocol level,
202+
// returning a proper JSON-RPC error (not a double-wrapped result).
203+
// Notifications (no id) are silently dropped so the tool never executes.
204+
if (!initialized && !p_input.is_empty()) {
205+
JSON json;
206+
if (json.parse(p_input) == OK) {
207+
Variant data = json.get_data();
208+
if (data.get_type() == Variant::DICTIONARY) {
209+
Dictionary req = data;
210+
String method = req.get("method", "");
211+
if (method == "tools/list" || method == "tools/call") {
212+
if (req.has("id")) {
213+
Variant id = req["id"];
214+
if (id.get_type() == Variant::FLOAT && id.operator float() == (float)(id.operator int())) {
215+
id = id.operator int();
216+
}
217+
Dictionary resp = make_response_error(INVALID_REQUEST, "Protocol not initialized", id);
218+
return JSON::stringify(resp);
219+
}
220+
// Notification before init: drop silently (no response permitted).
221+
return String();
222+
}
223+
}
224+
}
225+
}
226+
return JSONRPC::process_string(p_input);
227+
}

modules/mcp/mcp_protocol.h

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,12 @@ class MCPProtocol : public JSONRPC {
6666
Dictionary _make_capabilities() const;
6767
Dictionary _make_server_info() const;
6868
Array _get_tool_definitions() const;
69+
static String _negotiate_version(const String &p_client_version);
6970
/// @}
7071

7172
/// Make MCP-formatted tool result
7273
Dictionary _make_tool_result(const Array &p_content, bool p_is_error = false) const;
73-
Dictionary _make_text_content(const String &p_text) const;
74+
Dictionary _make_tool_result_error(const String &p_message) const;
7475

7576
protected:
7677
static void _bind_methods();
@@ -80,4 +81,8 @@ class MCPProtocol : public JSONRPC {
8081
~MCPProtocol();
8182

8283
bool is_initialized() const { return initialized; }
84+
85+
/// Override to gate pre-initialization requests (avoids double-wrapped errors).
86+
/// Shadows JSONRPC::process_string; called via MCPProtocol* static dispatch.
87+
String process_string(const String &p_input);
8388
};

0 commit comments

Comments
 (0)