#if TOOLS using Godot; using System; using System.Collections.Concurrent; using System.Net; using System.Text; using System.Threading; namespace Rokojori.Tools { using Rokojori; // Owned by RokojoriPlugin: Start()/Stop() from _EnterTree/_ExitTree, // ProcessPendingToolCalls() from the existing _Process tick. // // Threading: HttpListener runs its accept loop on a background thread. initialize, // notifications/initialized and tools/list are pure protocol/metadata and are answered // directly on that thread. Only tools/call touches Godot APIs (ResourceLoader, PackedScene, // the scene tree) - it is enqueued and the background thread blocks until the main thread // drains the queue and signals the result back. public class McpServer { public const string FlagFilePath = "res://.godot-mcp"; public const int PortRangeStart = 9400; public const int PortRangeCount = 20; HttpListener listener; Thread listenerThread; volatile bool running = false; int boundPort = -1; string projectPath = ""; readonly McpProtocolHandler protocol = new McpProtocolHandler(); readonly ConcurrentQueue pendingCalls = 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; listenerThread = new Thread( ListenLoop ); listenerThread.IsBackground = true; listenerThread.Start(); RJLog.Log( "MCP", "Server started on port", boundPort, "for project", projectPath ); } public void Stop() { if ( ! running ) { return; } running = false; // A tools/call request blocks the single HTTP thread on pending.waitHandle, waiting for // a future _Process() tick to dequeue and signal it. If that tick never comes (e.g. the // editor is pausing the main loop to hot-reload this very assembly), the thread stays // alive holding a reference into this assembly and the reload fails to unload it. 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 release any still-queued call right now. while ( pendingCalls.TryDequeue( out var pending ) ) { pending.outcome = ToolCallOutcome.Error( "MCP server is stopping" ); pending.waitHandle.Set(); } // The accept loop is parked inside the blocking listener.GetContext() call from its previous // iteration. HttpListener.Stop()/Close() are documented to abort that call, but that has not // proven reliable in practice - if it doesn't, the thread stays parked forever, which blocks // Godot's assembly unload on every future hot reload, not just one that catches a request // in flight. Firing one harmless loopback request guarantees GetContext() returns (with that // request) so the loop can observe running == false on its own and exit, instead of depending // on Stop()/Close() to interrupt it. WakeListenerThread(); try { listener?.Stop(); listener?.Close(); } catch ( Exception ) { } listenerThread?.Join( 2000 ); listener = null; boundPort = -1; RJLog.Log( "MCP", "Server stopped." ); } void WakeListenerThread() { if ( boundPort <= 0 ) { return; } try { using ( var client = new System.Net.Sockets.TcpClient() ) { client.Connect( "127.0.0.1", boundPort ); var bytes = Encoding.ASCII.GetBytes( "GET /__mcp_shutdown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" ); client.GetStream().Write( bytes, 0, bytes.Length ); } } catch ( Exception ) { } } // Drains tool-call requests queued by the HTTP thread. Must run on the main thread. public void ProcessPendingToolCalls() { while ( pendingCalls.TryDequeue( out var pending ) ) { ToolCallOutcome outcome; try { outcome = protocol.ExecuteTool( pending.toolName, pending.arguments ); } catch ( Exception e ) { RJLog.Error( "MCP", "Tool execution failed", pending.toolName, e ); outcome = ToolCallOutcome.Error( e.Message ); } pending.outcome = outcome; pending.waitHandle.Set(); } } 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; } } void ListenLoop() { while ( running ) { HttpListenerContext context; try { context = listener.GetContext(); } catch ( Exception ) { break; } try { HandleContext( context ); } catch ( Exception e ) { RJLog.Error( "MCP", "Unhandled error handling request", e ); try { context.Response.StatusCode = 500; context.Response.OutputStream.Close(); } catch ( Exception ) { } } } } 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(); } HandleJsonRpc( body, response ); } 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 ) { WriteJson( response, 200, 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 ) { WriteEmpty( response, 202 ); return; } switch ( method ) { case "initialize": WriteJson( response, 200, protocol.Initialize( id, paramsObj ) ); break; case "tools/list": WriteJson( response, 200, protocol.ToolsList( id ) ); break; case "tools/call": var outcome = DispatchToolCallBlocking( paramsObj ); WriteJson( response, 200, protocol.ToolCallResult( id, outcome ) ); break; default: WriteJson( response, 200, JsonRpc.BuildError( id, -32601, "Method not found: " + method ) ); break; } } ToolCallOutcome DispatchToolCallBlocking( JSONObject paramsObj ) { var toolName = JsonUtil.GetStringOrNull( paramsObj, "name" ); var arguments = paramsObj != null ? paramsObj.GetObject( "arguments" ) : null; if ( arguments == null ) { arguments = new JSONObject(); } var pending = new PendingToolCall( toolName, arguments ); pendingCalls.Enqueue( pending ); var completed = pending.waitHandle.Wait( TimeSpan.FromSeconds( 30 ) ); if ( ! completed ) { return ToolCallOutcome.Error( "Timed out waiting for the main thread to execute tool: " + toolName ); } return pending.outcome ?? ToolCallOutcome.Error( "Tool produced no result: " + toolName ); } 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