MCP Updates

This commit is contained in:
Rokojori 2026-10-03 14:15:58 +02:00
parent 79fd695989
commit 385a3069e4
31 changed files with 1662 additions and 1 deletions

View File

@ -65,6 +65,7 @@ public partial class RokojoriPlugin: EditorPlugin
GizmoDrawerPlugin gizmoDrawerPlugin = new GizmoDrawerPlugin(); GizmoDrawerPlugin gizmoDrawerPlugin = new GizmoDrawerPlugin();
GodotEditorInspectorTools inspectorTools = new GodotEditorInspectorTools(); GodotEditorInspectorTools inspectorTools = new GodotEditorInspectorTools();
DeduplicatingExportPlugin deduplicatingExportPlugin = new DeduplicatingExportPlugin(); DeduplicatingExportPlugin deduplicatingExportPlugin = new DeduplicatingExportPlugin();
Rokojori.Tools.McpServer mcpServer = new Rokojori.Tools.McpServer();
public static readonly string path = "res://addons/rokojori_action_library"; public static readonly string path = "res://addons/rokojori_action_library";
@ -168,12 +169,16 @@ public partial class RokojoriPlugin: EditorPlugin
inspectorTools.rokojoriPlugin = this; inspectorTools.rokojoriPlugin = this;
inspectorTools.AddPlugins(); inspectorTools.AddPlugins();
mcpServer.Start();
} }
public override void _ExitTree() public override void _ExitTree()
{ {
this.LogInfo(); this.LogInfo();
mcpServer.Stop();
RemoveNode3DGizmoPlugin( gizmoDrawerPlugin ); RemoveNode3DGizmoPlugin( gizmoDrawerPlugin );
RemoveExportPlugin( deduplicatingExportPlugin ); RemoveExportPlugin( deduplicatingExportPlugin );
@ -236,6 +241,8 @@ public partial class RokojoriPlugin: EditorPlugin
public override void _Process( double delta ) public override void _Process( double delta )
{ {
mcpServer.ProcessPendingToolCalls();
var editingScene = EditorInterface.Singleton.GetEditedSceneRoot(); var editingScene = EditorInterface.Singleton.GetEditedSceneRoot();
if ( lastUpdateTime.HasExpired( reloadDuration ) ) if ( lastUpdateTime.HasExpired( reloadDuration ) )

View File

@ -108,7 +108,7 @@ namespace Rokojori
// var startValue = ReflectionHelper.GetValue<float>( target, targetMember ); // var startValue = ReflectionHelper.GetValue<float>( target, targetMember );
var startValue = GetTargetValue( target, targetMemberPath ); var startValue = GetTargetValue( target, targetMemberPath );
if ( forceStartValue && Engine.IsEditorHint() ) if ( forceStartValue )
{ {
startValue = forcedStartValue; startValue = forcedStartValue;
} }

View File

@ -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"

View File

@ -361,6 +361,11 @@ namespace Rokojori
} }
occlusion.ApplyOcclusion( this ); occlusion.ApplyOcclusion( this );
if ( preset.disableFrustumCulling )
{
meshInstance.CustomAabb = Box3.FromPositionAndScale( Vector3.Zero, Vector3.One * 100000 );
}
} }
} }

View File

@ -37,6 +37,10 @@ namespace Rokojori
[Export] [Export]
public FlareLayer[] layers = []; public FlareLayer[] layers = [];
[Export]
public bool disableFrustumCulling = false;
[ExportGroup("Settings")] [ExportGroup("Settings")]
[Export] [Export]

44
Tools/mcp/JsonRpc.cs Normal file
View File

@ -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

1
Tools/mcp/JsonRpc.cs.uid Normal file
View File

@ -0,0 +1 @@
uid://clk267spq5gga

25
Tools/mcp/JsonUtil.cs Normal file
View File

@ -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

View File

@ -0,0 +1 @@
uid://c5sdnp77gvpl5

View File

@ -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

View File

@ -0,0 +1 @@
uid://byu52qd5hc76w

359
Tools/mcp/McpServer.cs Normal file
View File

@ -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<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

View File

@ -0,0 +1 @@
uid://njs03u236he2

View File

@ -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

View File

@ -0,0 +1 @@
uid://dy170v7wjkcj5

View File

@ -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<string, Variant.Type>();
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

View File

@ -0,0 +1 @@
uid://bdgxk1qlp7hqq

View File

@ -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

View File

@ -0,0 +1 @@
uid://bgmx7o7yhxauh

View File

@ -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<PackedScene>( 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

View File

@ -0,0 +1 @@
uid://bxa62u3hiuy3y

View File

@ -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<Resource>();
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

View File

@ -0,0 +1 @@
uid://c5f0mp0tkb8re

View File

@ -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<PackedScene>( 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<Node>();
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

View File

@ -0,0 +1 @@
uid://blb8c2s0ukunt

View File

@ -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

View File

@ -0,0 +1 @@
uid://d30pddlfk6qc2

View File

@ -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

View File

@ -0,0 +1 @@
uid://bx25ytgd1y8ho

365
Tools/mcp/VariantJson.cs Normal file
View File

@ -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

View File

@ -0,0 +1 @@
uid://dvmeiwgj8apa4