From 385a3069e44da52e3941ef3f5ea57a77fad35f5d Mon Sep 17 00:00:00 2001 From: Rokojori Date: Sat, 3 Oct 2026 14:15:58 +0200 Subject: [PATCH] MCP Updates --- RokojoriPlugin.cs | 7 + Runtime/Actions/Visual/TweenFloat.cs | 2 +- .../Float/Emission Strength.tres | 8 + Runtime/VFX/FlareVFX/FlareLayer.cs | 5 + Runtime/VFX/FlareVFX/FlarePreset.cs | 4 + Tools/mcp/JsonRpc.cs | 44 +++ Tools/mcp/JsonRpc.cs.uid | 1 + Tools/mcp/JsonUtil.cs | 25 ++ Tools/mcp/JsonUtil.cs.uid | 1 + Tools/mcp/McpProtocolHandler.cs | 85 ++++ Tools/mcp/McpProtocolHandler.cs.uid | 1 + Tools/mcp/McpServer.cs | 359 +++++++++++++++++ Tools/mcp/McpServer.cs.uid | 1 + Tools/mcp/PendingToolCall.cs | 24 ++ Tools/mcp/PendingToolCall.cs.uid | 1 + Tools/mcp/PropertyReflection.cs | 86 +++++ Tools/mcp/PropertyReflection.cs.uid | 1 + Tools/mcp/Routes/ReadResourceRoute.cs | 67 ++++ Tools/mcp/Routes/ReadResourceRoute.cs.uid | 1 + Tools/mcp/Routes/ReadSceneFragmentRoute.cs | 134 +++++++ .../mcp/Routes/ReadSceneFragmentRoute.cs.uid | 1 + Tools/mcp/Routes/WriteResourceRoute.cs | 108 ++++++ Tools/mcp/Routes/WriteResourceRoute.cs.uid | 1 + Tools/mcp/Routes/WriteSceneFragmentRoute.cs | 218 +++++++++++ .../mcp/Routes/WriteSceneFragmentRoute.cs.uid | 1 + Tools/mcp/ScenePathResolver.cs | 87 +++++ Tools/mcp/ScenePathResolver.cs.uid | 1 + Tools/mcp/ToolCallOutcome.cs | 22 ++ Tools/mcp/ToolCallOutcome.cs.uid | 1 + Tools/mcp/VariantJson.cs | 365 ++++++++++++++++++ Tools/mcp/VariantJson.cs.uid | 1 + 31 files changed, 1662 insertions(+), 1 deletion(-) create mode 100644 Runtime/Shading/Properties/Property Library/Float/Emission Strength.tres create mode 100644 Tools/mcp/JsonRpc.cs create mode 100644 Tools/mcp/JsonRpc.cs.uid create mode 100644 Tools/mcp/JsonUtil.cs create mode 100644 Tools/mcp/JsonUtil.cs.uid create mode 100644 Tools/mcp/McpProtocolHandler.cs create mode 100644 Tools/mcp/McpProtocolHandler.cs.uid create mode 100644 Tools/mcp/McpServer.cs create mode 100644 Tools/mcp/McpServer.cs.uid create mode 100644 Tools/mcp/PendingToolCall.cs create mode 100644 Tools/mcp/PendingToolCall.cs.uid create mode 100644 Tools/mcp/PropertyReflection.cs create mode 100644 Tools/mcp/PropertyReflection.cs.uid create mode 100644 Tools/mcp/Routes/ReadResourceRoute.cs create mode 100644 Tools/mcp/Routes/ReadResourceRoute.cs.uid create mode 100644 Tools/mcp/Routes/ReadSceneFragmentRoute.cs create mode 100644 Tools/mcp/Routes/ReadSceneFragmentRoute.cs.uid create mode 100644 Tools/mcp/Routes/WriteResourceRoute.cs create mode 100644 Tools/mcp/Routes/WriteResourceRoute.cs.uid create mode 100644 Tools/mcp/Routes/WriteSceneFragmentRoute.cs create mode 100644 Tools/mcp/Routes/WriteSceneFragmentRoute.cs.uid create mode 100644 Tools/mcp/ScenePathResolver.cs create mode 100644 Tools/mcp/ScenePathResolver.cs.uid create mode 100644 Tools/mcp/ToolCallOutcome.cs create mode 100644 Tools/mcp/ToolCallOutcome.cs.uid create mode 100644 Tools/mcp/VariantJson.cs create mode 100644 Tools/mcp/VariantJson.cs.uid diff --git a/RokojoriPlugin.cs b/RokojoriPlugin.cs index 639f7dd..39cec82 100644 --- a/RokojoriPlugin.cs +++ b/RokojoriPlugin.cs @@ -65,6 +65,7 @@ public partial class RokojoriPlugin: EditorPlugin GizmoDrawerPlugin gizmoDrawerPlugin = new GizmoDrawerPlugin(); GodotEditorInspectorTools inspectorTools = new GodotEditorInspectorTools(); DeduplicatingExportPlugin deduplicatingExportPlugin = new DeduplicatingExportPlugin(); + Rokojori.Tools.McpServer mcpServer = new Rokojori.Tools.McpServer(); public static readonly string path = "res://addons/rokojori_action_library"; @@ -168,12 +169,16 @@ public partial class RokojoriPlugin: EditorPlugin inspectorTools.rokojoriPlugin = this; inspectorTools.AddPlugins(); + + mcpServer.Start(); } public override void _ExitTree() { this.LogInfo(); + mcpServer.Stop(); + RemoveNode3DGizmoPlugin( gizmoDrawerPlugin ); RemoveExportPlugin( deduplicatingExportPlugin ); @@ -236,6 +241,8 @@ public partial class RokojoriPlugin: EditorPlugin public override void _Process( double delta ) { + mcpServer.ProcessPendingToolCalls(); + var editingScene = EditorInterface.Singleton.GetEditedSceneRoot(); if ( lastUpdateTime.HasExpired( reloadDuration ) ) diff --git a/Runtime/Actions/Visual/TweenFloat.cs b/Runtime/Actions/Visual/TweenFloat.cs index 9cbd103..c9284b0 100644 --- a/Runtime/Actions/Visual/TweenFloat.cs +++ b/Runtime/Actions/Visual/TweenFloat.cs @@ -108,7 +108,7 @@ namespace Rokojori // var startValue = ReflectionHelper.GetValue( target, targetMember ); var startValue = GetTargetValue( target, targetMemberPath ); - if ( forceStartValue && Engine.IsEditorHint() ) + if ( forceStartValue ) { startValue = forcedStartValue; } diff --git a/Runtime/Shading/Properties/Property Library/Float/Emission Strength.tres b/Runtime/Shading/Properties/Property Library/Float/Emission Strength.tres new file mode 100644 index 0000000..f428417 --- /dev/null +++ b/Runtime/Shading/Properties/Property Library/Float/Emission Strength.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="FloatPropertyName" format=3 uid="uid://c21kji5ygrnue"] + +[ext_resource type="Script" uid="uid://jqgdm3r2u8xq" path="res://addons/rokojori_action_library/Runtime/Shading/Properties/FloatPropertyName.cs" id="1_gn73r"] + +[resource] +script = ExtResource("1_gn73r") +propertyName = "emission_energy_multiplier" +metadata/_custom_type_script = "uid://jqgdm3r2u8xq" diff --git a/Runtime/VFX/FlareVFX/FlareLayer.cs b/Runtime/VFX/FlareVFX/FlareLayer.cs index b24660f..886f82c 100644 --- a/Runtime/VFX/FlareVFX/FlareLayer.cs +++ b/Runtime/VFX/FlareVFX/FlareLayer.cs @@ -361,6 +361,11 @@ namespace Rokojori } occlusion.ApplyOcclusion( this ); + + if ( preset.disableFrustumCulling ) + { + meshInstance.CustomAabb = Box3.FromPositionAndScale( Vector3.Zero, Vector3.One * 100000 ); + } } } diff --git a/Runtime/VFX/FlareVFX/FlarePreset.cs b/Runtime/VFX/FlareVFX/FlarePreset.cs index 3ab7833..54425bc 100644 --- a/Runtime/VFX/FlareVFX/FlarePreset.cs +++ b/Runtime/VFX/FlareVFX/FlarePreset.cs @@ -37,6 +37,10 @@ namespace Rokojori [Export] public FlareLayer[] layers = []; + [Export] + public bool disableFrustumCulling = false; + + [ExportGroup("Settings")] [Export] diff --git a/Tools/mcp/JsonRpc.cs b/Tools/mcp/JsonRpc.cs new file mode 100644 index 0000000..3256e3a --- /dev/null +++ b/Tools/mcp/JsonRpc.cs @@ -0,0 +1,44 @@ +#if TOOLS + +namespace Rokojori.Tools +{ + using Rokojori; + + public static class JsonRpc + { + public static JSONObject BuildResult( JSONData id, JSONData result ) + { + var obj = new JSONObject(); + obj.Set( "jsonrpc", "2.0" ); + obj.Set( "id", id ?? new JSONValue() ); + obj.Set( "result", result ); + return obj; + } + + public static JSONObject BuildError( JSONData id, int code, string message ) + { + var error = new JSONObject(); + error.Set( "code", (double) code ); + error.Set( "message", message ); + + var obj = new JSONObject(); + obj.Set( "jsonrpc", "2.0" ); + obj.Set( "id", id ?? new JSONValue() ); + obj.Set( "error", error ); + return obj; + } + + public static string GetMethod( JSONObject envelope ) + { + var data = envelope.Get( "method" ); + return data != null && data.isString ? data.stringValue : null; + } + + public static bool IsNotification( JSONObject envelope ) + { + return ! envelope.HasKey( "id" ); + } + } +} + +#endif diff --git a/Tools/mcp/JsonRpc.cs.uid b/Tools/mcp/JsonRpc.cs.uid new file mode 100644 index 0000000..c59535b --- /dev/null +++ b/Tools/mcp/JsonRpc.cs.uid @@ -0,0 +1 @@ +uid://clk267spq5gga diff --git a/Tools/mcp/JsonUtil.cs b/Tools/mcp/JsonUtil.cs new file mode 100644 index 0000000..6add6cd --- /dev/null +++ b/Tools/mcp/JsonUtil.cs @@ -0,0 +1,25 @@ +#if TOOLS + +namespace Rokojori.Tools +{ + using Rokojori; + + // JSONObject.GetString/GetNumber/GetBoolean etc. call Get(key) and dereference the result + // without a null check - they throw if the key is simply absent, which is the normal case + // for optional/missing request fields. This is the null-safe version for that situation. + public static class JsonUtil + { + public static string GetStringOrNull( JSONObject obj, string key ) + { + if ( obj == null ) + { + return null; + } + + var data = obj.Get( key ); + return data != null && data.isString ? data.stringValue : null; + } + } +} + +#endif diff --git a/Tools/mcp/JsonUtil.cs.uid b/Tools/mcp/JsonUtil.cs.uid new file mode 100644 index 0000000..d05eb6d --- /dev/null +++ b/Tools/mcp/JsonUtil.cs.uid @@ -0,0 +1 @@ +uid://c5sdnp77gvpl5 diff --git a/Tools/mcp/McpProtocolHandler.cs b/Tools/mcp/McpProtocolHandler.cs new file mode 100644 index 0000000..b8c11e6 --- /dev/null +++ b/Tools/mcp/McpProtocolHandler.cs @@ -0,0 +1,85 @@ +#if TOOLS + +namespace Rokojori.Tools +{ + using Rokojori; + + // Pure protocol logic - no Godot API access except inside ExecuteTool's route calls, + // which is why only tools/call needs to cross onto the main thread (see McpServer). + public class McpProtocolHandler + { + public JSONObject Initialize( JSONData id, JSONObject paramsObj ) + { + var requestedVersion = JsonUtil.GetStringOrNull( paramsObj, "protocolVersion" ); + var protocolVersion = string.IsNullOrEmpty( requestedVersion ) ? "2025-06-18" : requestedVersion; + + var capabilities = new JSONObject(); + capabilities.Set( "tools", new JSONObject() ); + + var serverInfo = new JSONObject(); + serverInfo.Set( "name", "godot-mcp" ); + serverInfo.Set( "version", "0.1.0" ); + + var result = new JSONObject(); + result.Set( "protocolVersion", protocolVersion ); + result.Set( "capabilities", capabilities ); + result.Set( "serverInfo", serverInfo ); + + return JsonRpc.BuildResult( id, result ); + } + + public JSONObject ToolsList( JSONData id ) + { + var tools = new JSONArray(); + tools.Push( ReadResourceRoute.ToolDefinition() ); + tools.Push( WriteResourceRoute.ToolDefinition() ); + tools.Push( ReadSceneFragmentRoute.ToolDefinition() ); + tools.Push( WriteSceneFragmentRoute.ToolDefinition() ); + + var result = new JSONObject(); + result.Set( "tools", tools ); + + return JsonRpc.BuildResult( id, result ); + } + + public JSONObject ToolCallResult( JSONData id, ToolCallOutcome outcome ) + { + var item = new JSONObject(); + item.Set( "type", "text" ); + item.Set( "text", outcome.text ); + + var content = new JSONArray(); + content.Push( item ); + + var result = new JSONObject(); + result.Set( "content", content ); + result.Set( "isError", outcome.isError ); + + return JsonRpc.BuildResult( id, result ); + } + + // Runs on the main thread, dequeued from McpServer's pending-call queue. + public ToolCallOutcome ExecuteTool( string toolName, JSONObject arguments ) + { + switch ( toolName ) + { + case "read_resource": + return ReadResourceRoute.Execute( arguments ); + + case "write_resource": + return WriteResourceRoute.Execute( arguments ); + + case "read_scene_fragment": + return ReadSceneFragmentRoute.Execute( arguments ); + + case "write_scene_fragment": + return WriteSceneFragmentRoute.Execute( arguments ); + + default: + return ToolCallOutcome.Error( "Unknown tool: " + toolName ); + } + } + } +} + +#endif diff --git a/Tools/mcp/McpProtocolHandler.cs.uid b/Tools/mcp/McpProtocolHandler.cs.uid new file mode 100644 index 0000000..41ec222 --- /dev/null +++ b/Tools/mcp/McpProtocolHandler.cs.uid @@ -0,0 +1 @@ +uid://byu52qd5hc76w diff --git a/Tools/mcp/McpServer.cs b/Tools/mcp/McpServer.cs new file mode 100644 index 0000000..61363dc --- /dev/null +++ b/Tools/mcp/McpServer.cs @@ -0,0 +1,359 @@ +#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 diff --git a/Tools/mcp/McpServer.cs.uid b/Tools/mcp/McpServer.cs.uid new file mode 100644 index 0000000..878c535 --- /dev/null +++ b/Tools/mcp/McpServer.cs.uid @@ -0,0 +1 @@ +uid://njs03u236he2 diff --git a/Tools/mcp/PendingToolCall.cs b/Tools/mcp/PendingToolCall.cs new file mode 100644 index 0000000..caf6fef --- /dev/null +++ b/Tools/mcp/PendingToolCall.cs @@ -0,0 +1,24 @@ +#if TOOLS + +using System.Threading; + +namespace Rokojori.Tools +{ + using Rokojori; + + public class PendingToolCall + { + public string toolName; + public JSONObject arguments; + public ManualResetEventSlim waitHandle = new ManualResetEventSlim( false ); + public ToolCallOutcome outcome; + + public PendingToolCall( string toolName, JSONObject arguments ) + { + this.toolName = toolName; + this.arguments = arguments; + } + } +} + +#endif diff --git a/Tools/mcp/PendingToolCall.cs.uid b/Tools/mcp/PendingToolCall.cs.uid new file mode 100644 index 0000000..3062528 --- /dev/null +++ b/Tools/mcp/PendingToolCall.cs.uid @@ -0,0 +1 @@ +uid://dy170v7wjkcj5 diff --git a/Tools/mcp/PropertyReflection.cs b/Tools/mcp/PropertyReflection.cs new file mode 100644 index 0000000..c0ed1cb --- /dev/null +++ b/Tools/mcp/PropertyReflection.cs @@ -0,0 +1,86 @@ +#if TOOLS + +using Godot; +using Godot.Collections; + +namespace Rokojori.Tools +{ + using Rokojori; + + // Walks GetPropertyList() filtered to PropertyUsageFlags.Storage, matching what + // Godot's own .tscn/.tres serializer persists - so round-trips don't add/lose properties. + public static class PropertyReflection + { + public static JSONObject ToJson( GodotObject target ) + { + var result = new JSONObject(); + var properties = target.GetPropertyList(); + + foreach ( var propertyDictionary in properties ) + { + var dict = (Dictionary) propertyDictionary; + + if ( ! IsStored( dict ) ) + { + continue; + } + + var name = dict[ "name" ].AsString(); + + if ( string.IsNullOrEmpty( name ) ) + { + continue; + } + + var value = target.Get( name ); + result.Set( name, VariantJson.ToJson( value ) ); + } + + return result; + } + + public static void ApplyFromJson( GodotObject target, JSONObject data ) + { + var properties = target.GetPropertyList(); + var typesByName = new System.Collections.Generic.Dictionary(); + + foreach ( var propertyDictionary in properties ) + { + var dict = (Dictionary) propertyDictionary; + var name = dict[ "name" ].AsString(); + + if ( ! string.IsNullOrEmpty( name ) ) + { + typesByName[ name ] = (Variant.Type) dict[ "type" ].AsInt32(); + } + } + + for ( var i = 0; i < data.keys.Count; i++ ) + { + var name = data.keys[ i ]; + + if ( ! typesByName.ContainsKey( name ) ) + { + RJLog.Error( "MCP PropertyReflection: unknown property", name, "on", target.GetClass() ); + continue; + } + + var variant = VariantJson.FromJson( data.values[ i ], typesByName[ name ] ); + target.Set( name, variant ); + } + } + + static bool IsStored( Dictionary propertyInfo ) + { + if ( ! propertyInfo.ContainsKey( "usage" ) ) + { + return false; + } + + var usage = propertyInfo[ "usage" ].AsInt64(); + return ( usage & (long) PropertyUsageFlags.Storage ) != 0; + } + } +} + +#endif diff --git a/Tools/mcp/PropertyReflection.cs.uid b/Tools/mcp/PropertyReflection.cs.uid new file mode 100644 index 0000000..3c437d9 --- /dev/null +++ b/Tools/mcp/PropertyReflection.cs.uid @@ -0,0 +1 @@ +uid://bdgxk1qlp7hqq diff --git a/Tools/mcp/Routes/ReadResourceRoute.cs b/Tools/mcp/Routes/ReadResourceRoute.cs new file mode 100644 index 0000000..e27b9b8 --- /dev/null +++ b/Tools/mcp/Routes/ReadResourceRoute.cs @@ -0,0 +1,67 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + using Rokojori; + + public static class ReadResourceRoute + { + public static JSONObject ToolDefinition() + { + var pathSchema = new JSONObject(); + pathSchema.Set( "type", "string" ); + pathSchema.Set( "description", "res:// path to the resource (.tres/.res)" ); + + var properties = new JSONObject(); + properties.Set( "path", pathSchema ); + + var required = new JSONArray(); + required.Push( "path" ); + + var schema = new JSONObject(); + schema.Set( "type", "object" ); + schema.Set( "properties", properties ); + schema.Set( "required", required ); + + var tool = new JSONObject(); + tool.Set( "name", "read_resource" ); + tool.Set( "description", "Read a Resource's stored properties as JSON." ); + tool.Set( "inputSchema", schema ); + + return tool; + } + + public static ToolCallOutcome Execute( JSONObject arguments ) + { + var path = JsonUtil.GetStringOrNull( arguments, "path" ); + + if ( string.IsNullOrEmpty( path ) ) + { + return ToolCallOutcome.Error( "Missing required argument: path" ); + } + + if ( ! ResourceLoader.Exists( path, "" ) ) + { + return ToolCallOutcome.Error( "Resource not found: " + path ); + } + + var resource = ResourceLoader.Load( path ); + + if ( resource == null ) + { + return ToolCallOutcome.Error( "Failed to load resource: " + path ); + } + + var data = new JSONObject(); + data.Set( "path", path ); + data.Set( "type", resource.GetClass() ); + data.Set( "properties", PropertyReflection.ToJson( resource ) ); + + return ToolCallOutcome.Ok( JSON.Stringify( data ) ); + } + } +} + +#endif diff --git a/Tools/mcp/Routes/ReadResourceRoute.cs.uid b/Tools/mcp/Routes/ReadResourceRoute.cs.uid new file mode 100644 index 0000000..e006efe --- /dev/null +++ b/Tools/mcp/Routes/ReadResourceRoute.cs.uid @@ -0,0 +1 @@ +uid://bgmx7o7yhxauh diff --git a/Tools/mcp/Routes/ReadSceneFragmentRoute.cs b/Tools/mcp/Routes/ReadSceneFragmentRoute.cs new file mode 100644 index 0000000..c3cec13 --- /dev/null +++ b/Tools/mcp/Routes/ReadSceneFragmentRoute.cs @@ -0,0 +1,134 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + using Rokojori; + + public static class ReadSceneFragmentRoute + { + public static JSONObject ToolDefinition() + { + var filePathSchema = new JSONObject(); + filePathSchema.Set( "type", "string" ); + filePathSchema.Set( "description", "res:// path to the scene (.tscn)" ); + + var scenePathSchema = new JSONObject(); + scenePathSchema.Set( "type", "array" ); + scenePathSchema.Set( "description", "Child names from the scene root to the target node. Empty array = scene root." ); + + var levelSchema = new JSONObject(); + levelSchema.Set( "type", "integer" ); + levelSchema.Set( "description", "Descendant depth to include: 0 = all, 1 = direct children only, 2 = two levels, etc. Defaults to 0." ); + + var properties = new JSONObject(); + properties.Set( "filePath", filePathSchema ); + properties.Set( "scenePath", scenePathSchema ); + properties.Set( "levelOfChildren", levelSchema ); + + var required = new JSONArray(); + required.Push( "filePath" ); + required.Push( "scenePath" ); + + var schema = new JSONObject(); + schema.Set( "type", "object" ); + schema.Set( "properties", properties ); + schema.Set( "required", required ); + + var tool = new JSONObject(); + tool.Set( "name", "read_scene_fragment" ); + tool.Set( "description", "Read a node subtree out of a scene file without instantiating it." ); + tool.Set( "inputSchema", schema ); + + return tool; + } + + public static ToolCallOutcome Execute( JSONObject arguments ) + { + var filePath = JsonUtil.GetStringOrNull( arguments, "filePath" ); + var scenePathArray = arguments != null ? arguments.GetArray( "scenePath" ) : null; + var levelData = arguments != null ? arguments.Get( "levelOfChildren" ) : null; + var levelOfChildren = levelData != null && levelData.isNumber ? levelData.intValue : 0; + + if ( string.IsNullOrEmpty( filePath ) ) + { + return ToolCallOutcome.Error( "Missing required argument: filePath" ); + } + + if ( ! ResourceLoader.Exists( filePath, "PackedScene" ) ) + { + return ToolCallOutcome.Error( "Scene not found: " + filePath ); + } + + var packedScene = ResourceLoader.Load( filePath ); + + if ( packedScene == null ) + { + return ToolCallOutcome.Error( "Failed to load scene: " + filePath ); + } + + var state = packedScene.GetState(); + var scenePath = ScenePathResolver.ToStringArray( scenePathArray ); + var targetRelativePath = ScenePathResolver.ToRelativePath( scenePath ); + var targetIndex = ScenePathResolver.FindNodeIndex( state, targetRelativePath ); + + if ( targetIndex == -1 ) + { + return ToolCallOutcome.Error( "Node not found at scenePath: " + targetRelativePath ); + } + + var startingLevels = levelOfChildren == 0 ? -1 : levelOfChildren; + var fragment = SerializeNode( state, targetIndex, targetRelativePath, startingLevels ); + + return ToolCallOutcome.Ok( JSON.Stringify( fragment ) ); + } + + static JSONObject SerializeNode( SceneState state, int index, string relativePath, int remainingLevels ) + { + var obj = new JSONObject(); + obj.Set( "name", state.GetNodeName( index ).ToString() ); + obj.Set( "type", state.GetNodeType( index ).ToString() ); + + var properties = new JSONObject(); + var propertyCount = state.GetNodePropertyCount( index ); + + for ( var i = 0; i < propertyCount; i++ ) + { + var propName = state.GetNodePropertyName( index, i ).ToString(); + var propValue = state.GetNodePropertyValue( index, i ); + properties.Set( propName, VariantJson.ToJson( propValue ) ); + } + + obj.Set( "properties", properties ); + + if ( remainingLevels == 0 ) + { + return obj; + } + + var children = new JSONObject(); + var nodeCount = state.GetNodeCount(); + var nextLevels = remainingLevels == -1 ? -1 : remainingLevels - 1; + + for ( var i = 0; i < nodeCount; i++ ) + { + var candidatePath = state.GetNodePath( i, false ).ToString(); + + if ( ScenePathResolver.DepthOf( relativePath, candidatePath ) != 1 ) + { + continue; + } + + var childName = state.GetNodeName( i ).ToString(); + children.Set( childName, SerializeNode( state, i, candidatePath, nextLevels ) ); + } + + obj.Set( "children", children ); + + return obj; + } + } +} + +#endif diff --git a/Tools/mcp/Routes/ReadSceneFragmentRoute.cs.uid b/Tools/mcp/Routes/ReadSceneFragmentRoute.cs.uid new file mode 100644 index 0000000..f0d3582 --- /dev/null +++ b/Tools/mcp/Routes/ReadSceneFragmentRoute.cs.uid @@ -0,0 +1 @@ +uid://bxa62u3hiuy3y diff --git a/Tools/mcp/Routes/WriteResourceRoute.cs b/Tools/mcp/Routes/WriteResourceRoute.cs new file mode 100644 index 0000000..6d67813 --- /dev/null +++ b/Tools/mcp/Routes/WriteResourceRoute.cs @@ -0,0 +1,108 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + using Rokojori; + + public static class WriteResourceRoute + { + public static JSONObject ToolDefinition() + { + var pathSchema = new JSONObject(); + pathSchema.Set( "type", "string" ); + pathSchema.Set( "description", "res:// path to write the resource to" ); + + var typeSchema = new JSONObject(); + typeSchema.Set( "type", "string" ); + typeSchema.Set( "description", "Godot class name, required only when creating a new resource at 'path'" ); + + var propertiesSchema = new JSONObject(); + propertiesSchema.Set( "type", "object" ); + propertiesSchema.Set( "description", "Property name -> JSON value map, as returned by read_resource" ); + + var properties = new JSONObject(); + properties.Set( "path", pathSchema ); + properties.Set( "type", typeSchema ); + properties.Set( "properties", propertiesSchema ); + + var required = new JSONArray(); + required.Push( "path" ); + required.Push( "properties" ); + + var schema = new JSONObject(); + schema.Set( "type", "object" ); + schema.Set( "properties", properties ); + schema.Set( "required", required ); + + var tool = new JSONObject(); + tool.Set( "name", "write_resource" ); + tool.Set( "description", "Create or update a Resource's stored properties and save it." ); + tool.Set( "inputSchema", schema ); + + return tool; + } + + public static ToolCallOutcome Execute( JSONObject arguments ) + { + var path = JsonUtil.GetStringOrNull( arguments, "path" ); + var typeName = JsonUtil.GetStringOrNull( arguments, "type" ); + var properties = arguments != null ? arguments.GetObject( "properties" ) : null; + + if ( string.IsNullOrEmpty( path ) ) + { + return ToolCallOutcome.Error( "Missing required argument: path" ); + } + + if ( properties == null ) + { + return ToolCallOutcome.Error( "Missing required argument: properties" ); + } + + Resource resource = null; + + if ( ResourceLoader.Exists( path, "" ) ) + { + resource = ResourceLoader.Load( path ); + } + + if ( resource == null ) + { + if ( string.IsNullOrEmpty( typeName ) ) + { + return ToolCallOutcome.Error( "Resource does not exist yet at '" + path + "' - 'type' is required to create it" ); + } + + if ( ! ClassDB.ClassExists( typeName ) ) + { + return ToolCallOutcome.Error( "Unknown class: " + typeName ); + } + + resource = ClassDB.Instantiate( typeName ).As(); + + if ( resource == null ) + { + return ToolCallOutcome.Error( "Class is not a Resource: " + typeName ); + } + } + + PropertyReflection.ApplyFromJson( resource, properties ); + + var error = ResourceSaver.Save( resource, path ); + + if ( error != Error.Ok ) + { + return ToolCallOutcome.Error( "Failed to save resource: " + error ); + } + + var result = new JSONObject(); + result.Set( "path", path ); + result.Set( "type", resource.GetClass() ); + + return ToolCallOutcome.Ok( JSON.Stringify( result ) ); + } + } +} + +#endif diff --git a/Tools/mcp/Routes/WriteResourceRoute.cs.uid b/Tools/mcp/Routes/WriteResourceRoute.cs.uid new file mode 100644 index 0000000..e79b344 --- /dev/null +++ b/Tools/mcp/Routes/WriteResourceRoute.cs.uid @@ -0,0 +1 @@ +uid://c5f0mp0tkb8re diff --git a/Tools/mcp/Routes/WriteSceneFragmentRoute.cs b/Tools/mcp/Routes/WriteSceneFragmentRoute.cs new file mode 100644 index 0000000..1fd1207 --- /dev/null +++ b/Tools/mcp/Routes/WriteSceneFragmentRoute.cs @@ -0,0 +1,218 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + using Rokojori; + + public static class WriteSceneFragmentRoute + { + public static JSONObject ToolDefinition() + { + var filePathSchema = new JSONObject(); + filePathSchema.Set( "type", "string" ); + filePathSchema.Set( "description", "res:// path to the scene (.tscn)" ); + + var scenePathSchema = new JSONObject(); + scenePathSchema.Set( "type", "array" ); + scenePathSchema.Set( "description", "Child names from the scene root to the node the fragment patches. Empty array = scene root. The node itself must already exist - only its descendants can be newly created." ); + + var fragmentSchema = new JSONObject(); + fragmentSchema.Set( "type", "object" ); + fragmentSchema.Set( "description", "Patch data: { properties: {...}, children: { childName: {...} | { \"__remove\": true } } }. Children are keyed by name, omitted children are left untouched." ); + + var properties = new JSONObject(); + properties.Set( "filePath", filePathSchema ); + properties.Set( "scenePath", scenePathSchema ); + properties.Set( "sceneFragmentData", fragmentSchema ); + + var required = new JSONArray(); + required.Push( "filePath" ); + required.Push( "scenePath" ); + required.Push( "sceneFragmentData" ); + + var schema = new JSONObject(); + schema.Set( "type", "object" ); + schema.Set( "properties", properties ); + schema.Set( "required", required ); + + var tool = new JSONObject(); + tool.Set( "name", "write_scene_fragment" ); + tool.Set( "description", "Patch a node subtree in a scene file: set properties, add/patch/remove children by name." ); + tool.Set( "inputSchema", schema ); + + return tool; + } + + public static ToolCallOutcome Execute( JSONObject arguments ) + { + var filePath = JsonUtil.GetStringOrNull( arguments, "filePath" ); + var scenePathArray = arguments != null ? arguments.GetArray( "scenePath" ) : null; + var fragmentData = arguments != null ? arguments.GetObject( "sceneFragmentData" ) : null; + + if ( string.IsNullOrEmpty( filePath ) ) + { + return ToolCallOutcome.Error( "Missing required argument: filePath" ); + } + + if ( fragmentData == null ) + { + return ToolCallOutcome.Error( "Missing required argument: sceneFragmentData" ); + } + + if ( ! ResourceLoader.Exists( filePath, "PackedScene" ) ) + { + return ToolCallOutcome.Error( "Scene not found: " + filePath ); + } + + var packedScene = ResourceLoader.Load( filePath ); + + if ( packedScene == null ) + { + return ToolCallOutcome.Error( "Failed to load scene: " + filePath ); + } + + // Plain Instantiate() resolves every property on every node to a concrete value, including + // ones a node only inherits from an instanced sub-scene. Pack() then has no way to tell + // "inherited" apart from "actually overridden" and bakes the current value of every property + // on every instanced node in the whole tree into an explicit override - corrupting unrelated + // sub-scenes (e.g. a vehicle model) far outside the patched fragment. GenEditState.Main keeps + // the instance-vs-override bookkeeping alive so Pack() only stores genuine overrides, matching + // how the editor itself instantiates a scene it's about to re-save as the main edited scene. + var root = packedScene.Instantiate( PackedScene.GenEditState.Main ); + + if ( root == null ) + { + return ToolCallOutcome.Error( "Failed to instantiate scene: " + filePath ); + } + + var scenePath = ScenePathResolver.ToStringArray( scenePathArray ); + var target = ScenePathResolver.FindNode( root, scenePath ); + + if ( target == null ) + { + root.Free(); + return ToolCallOutcome.Error( "Node not found at scenePath: " + ScenePathResolver.ToRelativePath( scenePath ) ); + } + + ApplyFragment( root, target, fragmentData ); + + var packError = packedScene.Pack( root ); + + if ( packError != Error.Ok ) + { + root.Free(); + return ToolCallOutcome.Error( "Failed to pack scene: " + packError ); + } + + var saveError = ResourceSaver.Save( packedScene, filePath ); + root.Free(); + + if ( saveError != Error.Ok ) + { + return ToolCallOutcome.Error( "Failed to save scene: " + saveError ); + } + + var result = new JSONObject(); + result.Set( "filePath", filePath ); + result.Set( "scenePath", ScenePathResolver.ToRelativePath( scenePath ) ); + + return ToolCallOutcome.Ok( JSON.Stringify( result ) ); + } + + static void ApplyFragment( Node root, Node target, JSONObject fragmentData ) + { + var properties = fragmentData.GetObject( "properties" ); + + if ( properties != null ) + { + PropertyReflection.ApplyFromJson( target, properties ); + } + + var children = fragmentData.GetObject( "children" ); + + if ( children == null ) + { + return; + } + + for ( var i = 0; i < children.keys.Count; i++ ) + { + var childName = children.keys[ i ]; + var childData = children.values[ i ].AsObject(); + + if ( childData == null ) + { + continue; + } + + var existingChild = FindDirectChild( target, childName ); + + if ( childData.Contains( "__remove" ) ) + { + if ( existingChild != null ) + { + target.RemoveChild( existingChild ); + existingChild.Free(); + } + + continue; + } + + if ( existingChild != null ) + { + ApplyFragment( root, existingChild, childData ); + continue; + } + + var typeName = JsonUtil.GetStringOrNull( childData, "type" ); + + if ( string.IsNullOrEmpty( typeName ) ) + { + RJLog.Error( "MCP WriteSceneFragment: new child needs a 'type'", childName ); + continue; + } + + if ( ! ClassDB.ClassExists( typeName ) ) + { + RJLog.Error( "MCP WriteSceneFragment: unknown class", typeName ); + continue; + } + + var newNode = ClassDB.Instantiate( typeName ).As(); + + if ( newNode == null ) + { + RJLog.Error( "MCP WriteSceneFragment: class is not a Node", typeName ); + continue; + } + + newNode.Name = childName; + target.AddChild( newNode ); + newNode.Owner = root; + + ApplyFragment( root, newNode, childData ); + } + } + + static Node FindDirectChild( Node parent, string name ) + { + var count = parent.GetChildCount( false ); + + for ( var i = 0; i < count; i++ ) + { + var child = parent.GetChild( i, false ); + + if ( child.Name.ToString() == name ) + { + return child; + } + } + + return null; + } + } +} + +#endif diff --git a/Tools/mcp/Routes/WriteSceneFragmentRoute.cs.uid b/Tools/mcp/Routes/WriteSceneFragmentRoute.cs.uid new file mode 100644 index 0000000..9ba3897 --- /dev/null +++ b/Tools/mcp/Routes/WriteSceneFragmentRoute.cs.uid @@ -0,0 +1 @@ +uid://blb8c2s0ukunt diff --git a/Tools/mcp/ScenePathResolver.cs b/Tools/mcp/ScenePathResolver.cs new file mode 100644 index 0000000..96369ff --- /dev/null +++ b/Tools/mcp/ScenePathResolver.cs @@ -0,0 +1,87 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + // scenePath is a list of child names from the scene root, e.g. ["Player","CharacterBody3D"]. + // SceneState has no parent/child API - nodes are a flat list, each with its own full relative + // path string (root is "."), so matching is by path-string comparison / prefix. + public static class ScenePathResolver + { + // SceneState.GetNodePath(idx, false) stringifies relative to the scene root as "./Name/Child", + // i.e. with an explicit "." leading segment - not a bare "Name/Child" as a naive join would produce. + public static string ToRelativePath( string[] scenePath ) + { + if ( scenePath == null || scenePath.Length == 0 ) + { + return "."; + } + + return "./" + string.Join( "/", scenePath ); + } + + public static int FindNodeIndex( SceneState state, string relativePath ) + { + var count = state.GetNodeCount(); + + for ( var i = 0; i < count; i++ ) + { + if ( state.GetNodePath( i, false ).ToString() == relativePath ) + { + return i; + } + } + + return -1; + } + + // 0 = same node, 1 = direct child, 2 = grandchild, etc. -1 = not a descendant. + public static int DepthOf( string rootRelativePath, string candidateRelativePath ) + { + if ( rootRelativePath == candidateRelativePath ) + { + return 0; + } + + var prefix = rootRelativePath == "." ? "./" : rootRelativePath + "/"; + + if ( ! candidateRelativePath.StartsWith( prefix ) ) + { + return -1; + } + + var remainder = candidateRelativePath.Substring( prefix.Length ); + return remainder.Split( '/' ).Length; + } + + public static Node FindNode( Node root, string[] scenePath ) + { + if ( scenePath == null || scenePath.Length == 0 ) + { + return root; + } + + return root.GetNodeOrNull( new NodePath( string.Join( "/", scenePath ) ) ); + } + + public static string[] ToStringArray( Rokojori.JSONArray array ) + { + if ( array == null ) + { + return new string[ 0 ]; + } + + var result = new string[ array.size ]; + + for ( var i = 0; i < array.size; i++ ) + { + result[ i ] = array.Get( i ).stringValue; + } + + return result; + } + } +} + +#endif diff --git a/Tools/mcp/ScenePathResolver.cs.uid b/Tools/mcp/ScenePathResolver.cs.uid new file mode 100644 index 0000000..804f5c5 --- /dev/null +++ b/Tools/mcp/ScenePathResolver.cs.uid @@ -0,0 +1 @@ +uid://d30pddlfk6qc2 diff --git a/Tools/mcp/ToolCallOutcome.cs b/Tools/mcp/ToolCallOutcome.cs new file mode 100644 index 0000000..c939d74 --- /dev/null +++ b/Tools/mcp/ToolCallOutcome.cs @@ -0,0 +1,22 @@ +#if TOOLS + +namespace Rokojori.Tools +{ + public class ToolCallOutcome + { + public string text; + public bool isError; + + public static ToolCallOutcome Ok( string text ) + { + return new ToolCallOutcome{ text = text, isError = false }; + } + + public static ToolCallOutcome Error( string text ) + { + return new ToolCallOutcome{ text = text, isError = true }; + } + } +} + +#endif diff --git a/Tools/mcp/ToolCallOutcome.cs.uid b/Tools/mcp/ToolCallOutcome.cs.uid new file mode 100644 index 0000000..eceda8b --- /dev/null +++ b/Tools/mcp/ToolCallOutcome.cs.uid @@ -0,0 +1 @@ +uid://bx25ytgd1y8ho diff --git a/Tools/mcp/VariantJson.cs b/Tools/mcp/VariantJson.cs new file mode 100644 index 0000000..728e87b --- /dev/null +++ b/Tools/mcp/VariantJson.cs @@ -0,0 +1,365 @@ +#if TOOLS + +using Godot; + +namespace Rokojori.Tools +{ + using Rokojori; + + // Two-way mapping between Godot Variant and the addon's own JSON types. + // Sub-resources are referenced by resource_path; an embedded sub-resource with + // no saved path round-trips as a tagged placeholder rather than its real data - + // not needed by the first 4 routes, revisit if a fragment ever needs it. + public static class VariantJson + { + public static JSONData ToJson( Variant v ) + { + switch ( v.VariantType ) + { + case Variant.Type.Nil: + return new JSONValue(); + + case Variant.Type.Bool: + return new JSONValue( v.AsBool() ); + + case Variant.Type.Int: + return new JSONValue( (double) v.AsInt64() ); + + case Variant.Type.Float: + return new JSONValue( v.AsDouble() ); + + case Variant.Type.String: + case Variant.Type.StringName: + return new JSONValue( v.AsString() ); + + case Variant.Type.NodePath: + return new JSONValue( v.AsNodePath().ToString() ); + + case Variant.Type.Vector2: + return Vector2ToJson( v.AsVector2() ); + + case Variant.Type.Vector2I: + var v2i = v.AsVector2I(); + return Vector2ToJson( new Vector2( v2i.X, v2i.Y ) ); + + case Variant.Type.Vector3: + return Vector3ToJson( v.AsVector3() ); + + case Variant.Type.Vector3I: + var v3i = v.AsVector3I(); + return Vector3ToJson( new Vector3( v3i.X, v3i.Y, v3i.Z ) ); + + case Variant.Type.Vector4: + return Vector4ToJson( v.AsVector4() ); + + case Variant.Type.Color: + return ColorToJson( v.AsColor() ); + + case Variant.Type.Array: + return ArrayToJson( v.AsGodotArray() ); + + case Variant.Type.Dictionary: + return DictionaryToJson( v.AsGodotDictionary() ); + + case Variant.Type.Object: + return ObjectToJson( v.AsGodotObject() ); + + default: + // Not modeled explicitly (Rid, Callable, Signal, Transform*, Basis, Plane, Quaternion, Projection, Aabb, Rect2...). + // Falls back to a readable string so round-tripping these doesn't crash - just loses write support. + return new JSONValue( v.ToString() ); + } + } + + public static Variant FromJson( JSONData data, Variant.Type targetType ) + { + if ( data == null || data.isNull ) + { + return new Variant(); + } + + switch ( targetType ) + { + case Variant.Type.Nil: + // Untyped [Export] Variant field - no declared type to convert against, shape-sniff instead. + return FromJsonInferred( data ); + + case Variant.Type.Bool: + return data.booleanValue; + + case Variant.Type.Int: + return data.longValue; + + case Variant.Type.Float: + return data.numberValue; + + case Variant.Type.String: + return data.stringValue; + + case Variant.Type.StringName: + return new StringName( data.stringValue ); + + case Variant.Type.NodePath: + return new NodePath( data.stringValue ); + + case Variant.Type.Vector2: + return JsonToVector2( data.AsObject() ); + + case Variant.Type.Vector2I: + var v2 = JsonToVector2( data.AsObject() ); + return new Vector2I( (int) v2.X, (int) v2.Y ); + + case Variant.Type.Vector3: + return JsonToVector3( data.AsObject() ); + + case Variant.Type.Vector3I: + var v3 = JsonToVector3( data.AsObject() ); + return new Vector3I( (int) v3.X, (int) v3.Y, (int) v3.Z ); + + case Variant.Type.Vector4: + return JsonToVector4( data.AsObject() ); + + case Variant.Type.Color: + return JsonToColor( data.AsObject() ); + + case Variant.Type.Array: + return JsonToArray( data.AsArray() ); + + case Variant.Type.Dictionary: + return JsonToDictionary( data.AsObject() ); + + case Variant.Type.Object: + return JsonToResource( data.AsObject() ); + + default: + RJLog.Error( "MCP VariantJson: write not supported for Variant type", targetType ); + return new Variant(); + } + } + + // Used for Array/Dictionary element values, which have no declared Variant type of their own - + // shape-sniffs the JSON (keys x/y/z/w, r/g/b/a, __resourcePath) rather than a real type lookup. + public static Variant FromJsonInferred( JSONData data ) + { + if ( data == null || data.isNull ) + { + return new Variant(); + } + + if ( data.isBoolean ) + { + return data.booleanValue; + } + + if ( data.isNumber ) + { + return data.numberValue; + } + + if ( data.isString ) + { + return data.stringValue; + } + + if ( data.isArray ) + { + return JsonToArray( data.AsArray() ); + } + + if ( data.isObject ) + { + var obj = data.AsObject(); + + if ( obj.Contains( "__resourcePath" ) ) + { + return JsonToResource( obj ); + } + + if ( obj.Contains( "x" ) && obj.Contains( "y" ) && obj.Contains( "z" ) && obj.Contains( "w" ) ) + { + return JsonToVector4( obj ); + } + + if ( obj.Contains( "r" ) && obj.Contains( "g" ) && obj.Contains( "b" ) ) + { + return JsonToColor( obj ); + } + + if ( obj.Contains( "x" ) && obj.Contains( "y" ) && obj.Contains( "z" ) ) + { + return JsonToVector3( obj ); + } + + if ( obj.Contains( "x" ) && obj.Contains( "y" ) ) + { + return JsonToVector2( obj ); + } + + return JsonToDictionary( obj ); + } + + return new Variant(); + } + + public static JSONObject Vector2ToJson( Vector2 v ) + { + var o = new JSONObject(); + o.Set( "x", (double) v.X ); + o.Set( "y", (double) v.Y ); + return o; + } + + public static JSONObject Vector3ToJson( Vector3 v ) + { + var o = new JSONObject(); + o.Set( "x", (double) v.X ); + o.Set( "y", (double) v.Y ); + o.Set( "z", (double) v.Z ); + return o; + } + + public static JSONObject Vector4ToJson( Vector4 v ) + { + var o = new JSONObject(); + o.Set( "x", (double) v.X ); + o.Set( "y", (double) v.Y ); + o.Set( "z", (double) v.Z ); + o.Set( "w", (double) v.W ); + return o; + } + + public static JSONObject ColorToJson( Color c ) + { + var o = new JSONObject(); + o.Set( "r", (double) c.R ); + o.Set( "g", (double) c.G ); + o.Set( "b", (double) c.B ); + o.Set( "a", (double) c.A ); + return o; + } + + public static Vector2 JsonToVector2( JSONObject o ) + { + if ( o == null ) return Vector2.Zero; + return new Vector2( (float) o.GetNumberOr( "x", 0 ), (float) o.GetNumberOr( "y", 0 ) ); + } + + public static Vector3 JsonToVector3( JSONObject o ) + { + if ( o == null ) return Vector3.Zero; + return new Vector3( (float) o.GetNumberOr( "x", 0 ), (float) o.GetNumberOr( "y", 0 ), (float) o.GetNumberOr( "z", 0 ) ); + } + + public static Vector4 JsonToVector4( JSONObject o ) + { + if ( o == null ) return Vector4.Zero; + return new Vector4( (float) o.GetNumberOr( "x", 0 ), (float) o.GetNumberOr( "y", 0 ), (float) o.GetNumberOr( "z", 0 ), (float) o.GetNumberOr( "w", 0 ) ); + } + + public static Color JsonToColor( JSONObject o ) + { + if ( o == null ) return Colors.White; + return new Color( + (float) o.GetNumberOr( "r", 1 ), (float) o.GetNumberOr( "g", 1 ), + (float) o.GetNumberOr( "b", 1 ), (float) o.GetNumberOr( "a", 1 ) + ); + } + + public static JSONArray ArrayToJson( Godot.Collections.Array array ) + { + var result = new JSONArray(); + + foreach ( var item in array ) + { + result.Push( ToJson( item ) ); + } + + return result; + } + + public static Godot.Collections.Array JsonToArray( JSONArray array ) + { + var result = new Godot.Collections.Array(); + + if ( array == null ) + { + return result; + } + + foreach ( var item in array.values ) + { + result.Add( FromJsonInferred( item ) ); + } + + return result; + } + + public static JSONObject DictionaryToJson( Godot.Collections.Dictionary dict ) + { + var result = new JSONObject(); + + foreach ( var key in dict.Keys ) + { + result.Set( key.AsString(), ToJson( dict[ key ] ) ); + } + + return result; + } + + public static Godot.Collections.Dictionary JsonToDictionary( JSONObject o ) + { + var result = new Godot.Collections.Dictionary(); + + if ( o == null ) + { + return result; + } + + for ( var i = 0; i < o.keys.Count; i++ ) + { + result[ o.keys[ i ] ] = FromJsonInferred( o.values[ i ] ); + } + + return result; + } + + public static JSONData ObjectToJson( GodotObject obj ) + { + if ( obj == null ) + { + return new JSONValue(); + } + + var wrapper = new JSONObject(); + + if ( obj is Resource resource ) + { + if ( ! string.IsNullOrEmpty( resource.ResourcePath ) ) + { + wrapper.Set( "__resourcePath", resource.ResourcePath ); + } + else + { + wrapper.Set( "__embeddedResourceType", resource.GetClass() ); + } + + return wrapper; + } + + return new JSONValue( obj.ToString() ); + } + + public static Resource JsonToResource( JSONObject o ) + { + if ( o == null || ! o.Contains( "__resourcePath" ) ) + { + return null; + } + + var path = o.GetString( "__resourcePath" ); + return ResourceLoader.Load( path ); + } + } +} + +#endif diff --git a/Tools/mcp/VariantJson.cs.uid b/Tools/mcp/VariantJson.cs.uid new file mode 100644 index 0000000..4051d89 --- /dev/null +++ b/Tools/mcp/VariantJson.cs.uid @@ -0,0 +1 @@ +uid://dvmeiwgj8apa4