rokojori_action_library/Tools/mcp/McpServer.cs

399 lines
12 KiB
C#
Raw Normal View History

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