rokojori_action_library/Tools/mcp/McpServer.cs

360 lines
10 KiB
C#

#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<PendingToolCall> pendingCalls = new ConcurrentQueue<PendingToolCall>();
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