2026-10-03 12:15:58 +00:00
|
|
|
#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,
|
2026-10-04 20:14:14 +00:00
|
|
|
// ProcessPendingRequests() from the existing _Process tick.
|
2026-10-03 12:15:58 +00:00
|
|
|
//
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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.
|
2026-10-03 12:15:58 +00:00
|
|
|
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();
|
2026-10-04 20:14:14 +00:00
|
|
|
readonly ConcurrentQueue<PendingRequest> pendingRequests = new ConcurrentQueue<PendingRequest>();
|
2026-10-03 12:15:58 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-10-04 20:14:14 +00:00
|
|
|
BeginAccept();
|
2026-10-03 12:15:58 +00:00
|
|
|
|
|
|
|
|
RJLog.Log( "MCP", "Server started on port", boundPort, "for project", projectPath );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Stop()
|
|
|
|
|
{
|
|
|
|
|
if ( ! running )
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
running = false;
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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 ) )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
AbortPendingRequest( pending );
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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.
|
2026-10-03 12:15:58 +00:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
listener?.Stop();
|
|
|
|
|
listener?.Close();
|
|
|
|
|
}
|
|
|
|
|
catch ( Exception )
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listener = null;
|
|
|
|
|
boundPort = -1;
|
|
|
|
|
|
|
|
|
|
RJLog.Log( "MCP", "Server stopped." );
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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 )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
JSONData id = new JSONValue();
|
2026-10-03 12:15:58 +00:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
var parsed = JSON.Parse( pending.body );
|
|
|
|
|
var envelope = parsed != null ? parsed.AsObject() : null;
|
|
|
|
|
var parsedId = envelope != null ? envelope.Get( "id" ) : null;
|
|
|
|
|
|
|
|
|
|
if ( parsedId != null )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
id = parsedId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch ( Exception )
|
|
|
|
|
{
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( pending.response, protocol.ToolCallResult( id, ToolCallOutcome.Error( "MCP server is stopping" ) ) );
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
void BeginAccept()
|
|
|
|
|
{
|
|
|
|
|
if ( ! running || listener == null )
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
listener.BeginGetContext( OnGetContext, null );
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
catch ( Exception )
|
|
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
// Listener already stopped/closed - nothing to accept.
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
void OnGetContext( IAsyncResult ar )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
HttpListenerContext context;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
context = listener.EndGetContext( ar );
|
|
|
|
|
}
|
|
|
|
|
catch ( Exception )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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 );
|
2026-10-03 12:15:58 +00:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
context.Response.StatusCode = 500;
|
|
|
|
|
context.Response.OutputStream.Close();
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
2026-10-04 20:14:14 +00:00
|
|
|
catch ( Exception )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
|
|
|
|
}
|
2026-10-04 20:14:14 +00:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
BeginAccept();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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 );
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// 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.
|
2026-10-03 12:15:58 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
pendingRequests.Enqueue( new PendingRequest( body, response ) );
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
// Runs on the main thread only, dequeued from pendingRequests by ProcessPendingRequests().
|
2026-10-03 12:15:58 +00:00
|
|
|
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 )
|
|
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( response, JsonRpc.BuildError( new JSONValue(), -32700, "Parse error" ) );
|
2026-10-03 12:15:58 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var id = envelope.Get( "id" );
|
|
|
|
|
var isNotification = JsonRpc.IsNotification( envelope );
|
|
|
|
|
var method = JsonRpc.GetMethod( envelope );
|
|
|
|
|
var paramsObj = envelope.GetObject( "params" );
|
|
|
|
|
|
|
|
|
|
if ( isNotification )
|
|
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteEmpty( response, 202 );
|
2026-10-03 12:15:58 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch ( method )
|
|
|
|
|
{
|
|
|
|
|
case "initialize":
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( response, protocol.Initialize( id, paramsObj ) );
|
2026-10-03 12:15:58 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "tools/list":
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( response, protocol.ToolsList( id ) );
|
2026-10-03 12:15:58 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "tools/call":
|
2026-10-04 20:14:14 +00:00
|
|
|
HandleToolCall( id, paramsObj, response );
|
2026-10-03 12:15:58 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
default:
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( response, JsonRpc.BuildError( id, -32601, "Method not found: " + method ) );
|
2026-10-03 12:15:58 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
void HandleToolCall( JSONData id, JSONObject paramsObj, HttpListenerResponse response )
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
|
|
|
|
var toolName = JsonUtil.GetStringOrNull( paramsObj, "name" );
|
|
|
|
|
var arguments = paramsObj != null ? paramsObj.GetObject( "arguments" ) : null;
|
|
|
|
|
|
|
|
|
|
if ( arguments == null )
|
|
|
|
|
{
|
|
|
|
|
arguments = new JSONObject();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
ToolCallOutcome outcome;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
outcome = protocol.ExecuteTool( toolName, arguments );
|
|
|
|
|
}
|
|
|
|
|
catch ( Exception e )
|
|
|
|
|
{
|
|
|
|
|
RJLog.Error( "MCP", "Tool execution failed", toolName, e );
|
|
|
|
|
outcome = ToolCallOutcome.Error( e.Message );
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
TryWriteJson( response, protocol.ToolCallResult( id, outcome ) );
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
void TryWriteJson( HttpListenerResponse response, JSONData data )
|
|
|
|
|
{
|
|
|
|
|
try
|
2026-10-03 12:15:58 +00:00
|
|
|
{
|
2026-10-04 20:14:14 +00:00
|
|
|
WriteJson( response, 200, data );
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
2026-10-04 20:14:14 +00:00
|
|
|
catch ( Exception e )
|
|
|
|
|
{
|
|
|
|
|
// Client likely disconnected before the response could be written - nothing to recover.
|
|
|
|
|
RJLog.Error( "MCP", "Failed to write response", e );
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
|
2026-10-04 20:14:14 +00:00
|
|
|
void TryWriteEmpty( HttpListenerResponse response, int statusCode )
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
WriteEmpty( response, statusCode );
|
|
|
|
|
}
|
|
|
|
|
catch ( Exception e )
|
|
|
|
|
{
|
|
|
|
|
RJLog.Error( "MCP", "Failed to write response", e );
|
|
|
|
|
}
|
2026-10-03 12:15:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|