360 lines
10 KiB
C#
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
|