#if TOOLS using Godot; using System; using System.Collections.Concurrent; using System.Net; using System.Text; namespace Rokojori.Tools { using Rokojori; // Owned by RokojoriPlugin: Start()/Stop() from _EnterTree/_ExitTree, // ProcessPendingRequests() from the existing _Process tick. // // Threading: HttpListener accepts via BeginGetContext/EndGetContext (thread-pool callbacks), // not a dedicated foreground/background Thread blocked in GetContext() - no thread is ever // blocked waiting on anything in this class, which matters because a blocked thread with a // stack frame into this assembly keeps Godot's C# hot reload from unloading it until that // thread returns, and relying on Stop()/Close() to reliably abort such a thread has not held // up in practice. // // More fundamentally: the HTTP callback thread does raw byte I/O only - read the request body // in, hand it off untouched, write the response bytes out when told to. It never parses JSON, // dispatches protocol methods, or executes a tool. That's not just about tools/call touching // Godot APIs directly (ResourceLoader, PackedScene, the scene tree) - the shared JSON library // this server depends on (JSONParser) calls RJLog on malformed input, and RJLog ultimately // calls GD.Print/GD.PrintErr, which are themselves Godot engine API calls. Every one of those // is only safe from the main thread; calling into the engine from a background thread can // corrupt native-side state in ways that surface as hot-reload failures. So ALL of it - JSON. // Parse included - is deferred to ProcessPendingRequests() on the main thread via // pendingRequests, and the response is written from there too. public class McpServer { public const string FlagFilePath = "res://.godot-mcp"; public const int PortRangeStart = 9400; public const int PortRangeCount = 20; HttpListener listener; volatile bool running = false; int boundPort = -1; string projectPath = ""; readonly McpProtocolHandler protocol = new McpProtocolHandler(); readonly ConcurrentQueue pendingRequests = new ConcurrentQueue(); public bool IsRunning { get { return running; } } public int BoundPort { get { return boundPort; } } public void Start() { if ( running ) { return; } var flagGlobalPath = ProjectSettings.GlobalizePath( FlagFilePath ); if ( ! FilesSync.FileExists( flagGlobalPath ) ) { RJLog.Log( "MCP", "Flag file not found (" + FlagFilePath + ") - server not starting." ); return; } projectPath = ProjectSettings.GlobalizePath( "res://" ); for ( var port = PortRangeStart; port < PortRangeStart + PortRangeCount; port++ ) { if ( TryBind( port ) ) { boundPort = port; break; } } if ( listener == null ) { RJLog.Error( "MCP", "Could not bind any port in range", PortRangeStart, "-", ( PortRangeStart + PortRangeCount - 1 ) ); return; } running = true; BeginAccept(); RJLog.Log( "MCP", "Server started on port", boundPort, "for project", projectPath ); } public void Stop() { if ( ! running ) { return; } running = false; // Any request still waiting for a future _Process() tick to dequeue it would otherwise sit // unanswered forever if that tick never comes (e.g. the editor pausing the main loop to // hot-reload this very assembly). Stop() always runs on the main thread, never concurrently // with _Process(), so it can safely stand in for the tick that won't arrive and answer any // still-queued request right now instead of leaving its HTTP response hanging. while ( pendingRequests.TryDequeue( out var pending ) ) { AbortPendingRequest( pending ); } // Closing the listener faults any pending BeginGetContext registration, which invokes // OnGetContext on a thread-pool callback thread with an exception from EndGetContext() - // handled there by simply not chaining another BeginAccept(). No dedicated thread to join. try { listener?.Stop(); listener?.Close(); } catch ( Exception ) { } listener = null; boundPort = -1; RJLog.Log( "MCP", "Server stopped." ); } // Stop() runs on the main thread, so parsing here (to recover the request's id for a clean // JSON-RPC error) is safe even though JSON.Parse can call RJLog on malformed input. void AbortPendingRequest( PendingRequest pending ) { JSONData id = new JSONValue(); try { var parsed = JSON.Parse( pending.body ); var envelope = parsed != null ? parsed.AsObject() : null; var parsedId = envelope != null ? envelope.Get( "id" ) : null; if ( parsedId != null ) { id = parsedId; } } catch ( Exception ) { } TryWriteJson( pending.response, protocol.ToolCallResult( id, ToolCallOutcome.Error( "MCP server is stopping" ) ) ); } void BeginAccept() { if ( ! running || listener == null ) { return; } try { listener.BeginGetContext( OnGetContext, null ); } catch ( Exception ) { // Listener already stopped/closed - nothing to accept. } } void OnGetContext( IAsyncResult ar ) { HttpListenerContext context; try { context = listener.EndGetContext( ar ); } catch ( Exception ) { // Stop() closed the listener while this accept was pending - end the chain here. return; } try { HandleContext( context ); } catch ( Exception e ) { // This runs on a thread-pool callback thread, not the main thread. Console.Error is // plain .NET, not a Godot API - unlike RJLog, it carries no risk of touching the engine // from the wrong thread (see the class comment above). Console.Error.WriteLine( "[MCP] Unhandled error handling request: " + e ); try { context.Response.StatusCode = 500; context.Response.OutputStream.Close(); } catch ( Exception ) { } } finally { BeginAccept(); } } // Drains requests queued by the HTTP callback thread and handles them fully - JSON parsing, // protocol dispatch, tool execution, and writing the HTTP response - on the main thread. // Nothing on the HTTP side ever blocks waiting for this to happen. public void ProcessPendingRequests() { while ( pendingRequests.TryDequeue( out var pending ) ) { HandleJsonRpc( pending.body, pending.response ); } } bool TryBind( int port ) { try { var candidate = new HttpListener(); // http.sys matches the Host header against registered prefixes exactly - "localhost" // and "127.0.0.1" don't match each other, so both are registered since the MCP client // config could plausibly use either. candidate.Prefixes.Add( "http://localhost:" + port + "/" ); candidate.Prefixes.Add( "http://127.0.0.1:" + port + "/" ); candidate.Start(); listener = candidate; return true; } catch ( HttpListenerException ) { return false; } catch ( System.Net.Sockets.SocketException ) { return false; } } // Runs on the HTTP callback thread. The /project-path and 404 branches only touch plain .NET // request/response objects and cached strings/ints, so they're answered directly here. A // /mcp POST body is handed off untouched - see the class comment for why. void HandleContext( HttpListenerContext context ) { var request = context.Request; var response = context.Response; if ( request.HttpMethod == "GET" && request.Url.AbsolutePath == "/project-path" ) { var discovery = new JSONObject(); discovery.Set( "projectPath", projectPath ); discovery.Set( "port", (double) boundPort ); WriteJson( response, 200, discovery ); return; } if ( request.HttpMethod != "POST" || request.Url.AbsolutePath != "/mcp" ) { response.StatusCode = 404; response.OutputStream.Close(); return; } string body; using ( var reader = new System.IO.StreamReader( request.InputStream, request.ContentEncoding ?? Encoding.UTF8 ) ) { body = reader.ReadToEnd(); } pendingRequests.Enqueue( new PendingRequest( body, response ) ); } // Runs on the main thread only, dequeued from pendingRequests by ProcessPendingRequests(). void HandleJsonRpc( string body, HttpListenerResponse response ) { JSONObject envelope; try { var parsed = JSON.Parse( body ); envelope = parsed != null ? parsed.AsObject() : null; if ( envelope == null ) { throw new Exception( "Request body is not a JSON object" ); } } catch ( Exception ) { TryWriteJson( response, JsonRpc.BuildError( new JSONValue(), -32700, "Parse error" ) ); return; } var id = envelope.Get( "id" ); var isNotification = JsonRpc.IsNotification( envelope ); var method = JsonRpc.GetMethod( envelope ); var paramsObj = envelope.GetObject( "params" ); if ( isNotification ) { TryWriteEmpty( response, 202 ); return; } switch ( method ) { case "initialize": TryWriteJson( response, protocol.Initialize( id, paramsObj ) ); break; case "tools/list": TryWriteJson( response, protocol.ToolsList( id ) ); break; case "tools/call": HandleToolCall( id, paramsObj, response ); break; default: TryWriteJson( response, JsonRpc.BuildError( id, -32601, "Method not found: " + method ) ); break; } } void HandleToolCall( JSONData id, JSONObject paramsObj, HttpListenerResponse response ) { var toolName = JsonUtil.GetStringOrNull( paramsObj, "name" ); var arguments = paramsObj != null ? paramsObj.GetObject( "arguments" ) : null; if ( arguments == null ) { arguments = new JSONObject(); } ToolCallOutcome outcome; try { outcome = protocol.ExecuteTool( toolName, arguments ); } catch ( Exception e ) { RJLog.Error( "MCP", "Tool execution failed", toolName, e ); outcome = ToolCallOutcome.Error( e.Message ); } TryWriteJson( response, protocol.ToolCallResult( id, outcome ) ); } void TryWriteJson( HttpListenerResponse response, JSONData data ) { try { WriteJson( response, 200, data ); } catch ( Exception e ) { // Client likely disconnected before the response could be written - nothing to recover. RJLog.Error( "MCP", "Failed to write response", e ); } } void TryWriteEmpty( HttpListenerResponse response, int statusCode ) { try { WriteEmpty( response, statusCode ); } catch ( Exception e ) { RJLog.Error( "MCP", "Failed to write response", e ); } } static void WriteJson( HttpListenerResponse response, int statusCode, JSONData data ) { var text = data == null ? "" : JSON.Stringify( data ); var bytes = Encoding.UTF8.GetBytes( text ); response.StatusCode = statusCode; response.ContentType = "application/json"; response.ContentLength64 = bytes.Length; response.OutputStream.Write( bytes, 0, bytes.Length ); response.OutputStream.Close(); } static void WriteEmpty( HttpListenerResponse response, int statusCode ) { response.StatusCode = statusCode; response.ContentLength64 = 0; response.OutputStream.Close(); } } } #endif