Editor tooling for the MCP assistant. It exposes several reflection and runtime inspection tools for the editor/runtime: InvokeComponent (finds a GameObject or Component and invokes a method via reflection with JSON args), InspectRuntime (reads transforms, rigidbody and component properties), SimulationStep (steps physics/play mode for frames or duration) and GameplayStep (synthesizes input, steps physics and reports displacement).
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Sandbox;
using Component = Sandbox.Component;
namespace Editor.Mcp
{
public static partial class SboxMcpAssistant
{
/// <summary>
/// Invokes any public or internal method on any component in the scene via reflection.
/// Gives full programmatic control to trigger combat actions, damage, or state machine events.
/// </summary>
[McpTool( "pocketknife_invoke_component" )]
public static string InvokeComponent(
[Description( "Target GameObject name, Id, or Component Id" )] string target,
[Description( "Name of the method to invoke (e.g. 'TriggerKnockout', 'TakeDamage', 'Punch')" )] string methodName,
[Description( "Component type name (e.g. 'ActiveRagdollComponent', 'Rigidbody'). Optional if target is component Id or method is unique." )] string? componentType = null,
[Description( "JSON array of argument values (e.g. '[50.0]', '[\"true\"]', or '[10, 20, 30]')" )] string? argumentsJson = null )
{
try
{
if ( string.IsNullOrWhiteSpace( target ) || string.IsNullOrWhiteSpace( methodName ) )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "invoke.args_required", "Target and methodName are required." ) );
}
var scene = Game.ActiveScene ?? EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" ) as Scene;
if ( scene == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.not_found", "No active scene found." ) );
}
// Find target GameObject or component
GameObject? targetGo = null;
Component? targetComp = null;
// Try finding by GameObject ID or name
targetGo = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty );
if ( targetGo == null )
{
targetGo = scene.GetAllObjects( false ).FirstOrDefault( go => string.Equals( go.Name, target, StringComparison.OrdinalIgnoreCase ) );
}
if ( targetGo == null )
{
// Check if target is directly a component GUID
if ( guid != Guid.Empty )
{
targetComp = scene.GetAllComponents<Component>().FirstOrDefault( c => c.Id == guid );
if ( targetComp != null ) targetGo = targetComp.GameObject;
}
}
if ( targetGo == null && targetComp == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Failed, "target.not_found", $"Target '{target}' could not be resolved." ) );
}
// Find component on GameObject
if ( targetComp == null && targetGo != null )
{
var comps = targetGo.Components.GetAll<Component>().ToArray();
if ( !string.IsNullOrWhiteSpace( componentType ) )
{
targetComp = comps.FirstOrDefault( c =>
string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase ) ||
string.Equals( c.GetType().FullName, componentType, StringComparison.OrdinalIgnoreCase ) );
}
else
{
// Find first component containing the requested method
targetComp = comps.FirstOrDefault( c => c.GetType().GetMethod( methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase ) != null );
}
}
if ( targetComp == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Target = targetGo?.Name, ComponentType = componentType }, ToolStatus.Failed, "component.not_found", "Matching component not found on target." ) );
}
// Resolve method
var compTypeObj = targetComp.GetType();
var methods = compTypeObj.GetMethods( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance )
.Where( m => string.Equals( m.Name, methodName, StringComparison.OrdinalIgnoreCase ) )
.ToArray();
if ( methods.Length == 0 )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Component = compTypeObj.Name, Method = methodName }, ToolStatus.Failed, "method.not_found", $"Method '{methodName}' not found on {compTypeObj.Name}." ) );
}
// Parse arguments
JsonArray? jsonArgs = null;
if ( !string.IsNullOrWhiteSpace( argumentsJson ) )
{
try
{
var node = JsonNode.Parse( argumentsJson );
jsonArgs = node as JsonArray ?? new JsonArray( node );
}
catch
{
// Fallback: single scalar string
jsonArgs = new JsonArray( JsonValue.Create( argumentsJson ) );
}
}
var argCount = jsonArgs?.Count ?? 0;
var targetMethod = methods.FirstOrDefault( m => m.GetParameters().Length == argCount ) ?? methods.First();
var methodParams = targetMethod.GetParameters();
var invokeArgs = new object?[methodParams.Length];
for ( int i = 0; i < methodParams.Length; i++ )
{
var p = methodParams[i];
if ( jsonArgs != null && i < jsonArgs.Count )
{
invokeArgs[i] = ConvertJsonNode( jsonArgs[i], p.ParameterType );
}
else if ( p.HasDefaultValue )
{
invokeArgs[i] = p.DefaultValue;
}
else
{
invokeArgs[i] = p.ParameterType.IsValueType ? Activator.CreateInstance( p.ParameterType ) : null;
}
}
var result = targetMethod.Invoke( targetComp, invokeArgs );
return McpJson.Write( ToolOutcome<object>.Ready( new
{
TargetGameObject = targetGo?.Name,
Component = compTypeObj.Name,
Method = targetMethod.Name,
Parameters = methodParams.Select( ( p, i ) => new { p.Name, Type = p.ParameterType.Name, Value = invokeArgs[i]?.ToString() } ),
ReturnValue = result?.ToString(),
Executed = true
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "invoke.failed", "Component method invocation failed.", ex ) );
}
}
/// <summary>
/// Inspects live runtime transforms, physics velocities, and component states of a GameObject during play mode.
/// </summary>
[McpTool( "pocketknife_inspect_runtime" )]
public static string InspectRuntime(
[Description( "Target GameObject name or Id" )] string target,
[Description( "Optional component type name to filter" )] string? componentType = null )
{
try
{
var scene = EditorRuntime.ResolveActiveScene();
if ( scene == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "Active scene is not loaded." ) );
}
var go = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty )
?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, target, StringComparison.OrdinalIgnoreCase ) );
if ( go == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Target = target }, ToolStatus.Failed, "target.not_found", $"GameObject '{target}' not found." ) );
}
var rb = go.GetComponent<Rigidbody>();
var comps = go.Components.GetAll<Component>()
.Where( c => string.IsNullOrWhiteSpace( componentType ) || string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase ) )
.Select( c => new
{
Type = c.GetType().Name,
Enabled = c.Enabled,
Properties = c.GetType().GetProperties( BindingFlags.Public | BindingFlags.Instance )
.Where( p => p.CanRead && p.GetIndexParameters().Length == 0 )
.ToDictionary( p => p.Name, p => SafeRead( p, c ) )
} )
.ToArray();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Name = go.Name,
Id = go.Id.ToString(),
Enabled = go.Enabled,
WorldPosition = go.WorldPosition,
WorldRotation = go.WorldRotation.Angles(),
LocalPosition = go.LocalPosition,
Physics = rb != null ? new
{
Mass = rb.Mass,
Velocity = rb.Velocity,
AngularVelocity = rb.AngularVelocity,
IsStatic = rb.MotionEnabled == false
} : null,
Components = comps
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "inspect.failed", "Runtime inspection failed.", ex ) );
}
}
/// <summary>
/// Steps the simulation by a fixed number of frames or duration and leaves it paused for inspection.
/// </summary>
[McpTool( "pocketknife_simulation_step" )]
public static async Task<string> SimulationStep(
[Description( "Number of simulation frames to advance (1-300)" )] int frames = 1,
[Description( "Duration in milliseconds to run before pausing (if > 0, overrides frames)" )] int durationMs = 0,
[Description( "Whether to ensure play mode is paused after stepping" )] bool autoPause = true )
{
try
{
if ( !Game.IsPlaying )
{
// Automatically start play mode if stopped
EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
await Task.Delay( 300 );
}
var scene = EditorRuntime.ResolveActiveScene();
if ( scene == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "scene.inactive", "No active play scene." ) );
}
if ( durationMs > 0 )
{
var boundedMs = Math.Clamp( durationMs, 10, 10000 );
// Unpause if paused
if ( EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayPause" ) is not null )
{
await Task.Delay( boundedMs );
if ( autoPause )
{
EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayPause" );
}
}
return McpJson.Write( ToolOutcome<object>.Ready( new { SteppedDurationMs = boundedMs, Paused = autoPause, Time = Sandbox.Time.Now } ) );
}
var boundedFrames = Math.Clamp( frames, 1, 300 );
for ( int i = 0; i < boundedFrames; i++ )
{
scene.PhysicsWorld?.Step( 1.0f / 60.0f );
await Task.Delay( 16 );
}
return McpJson.Write( ToolOutcome<object>.Ready( new
{
SteppedFrames = boundedFrames,
Paused = autoPause,
Time = Sandbox.Time.Now,
PhysicsTime = scene.PhysicsWorld?.IsValid == true
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "step.failed", "Simulation stepping failed.", ex ) );
}
}
/// <summary>
/// Single compound tool: dispatches player input, steps physics, tracks transform & displacement,
/// and optionally captures a visual verification screenshot in one call.
/// </summary>
[McpTool( "pocketknife_gameplay_step" )]
public static async Task<string> GameplayStep(
[Description( "Action to simulate (e.g. 'Forward', 'Jump', 'Attack1')" )] string action,
[Description( "Pulse duration in milliseconds (10-5000)" )] int durationMs = 150,
[Description( "State: pulse, down, up" )] string state = "pulse",
[Description( "Target GameObject name or Id to track before and after" )] string? trackTarget = null,
[Description( "Player index for local multiplayer (0-3)" )] int playerIndex = 0 )
{
try
{
if ( !Game.IsPlaying )
{
EditorRuntime.InvokeStatic( "Editor.Mcp.PlayTools", "PlayStart" );
await Task.Delay( 300 );
}
var scene = EditorRuntime.ResolveActiveScene();
GameObject? targetGo = null;
Vector3 startPos = Vector3.Zero;
Angles startAngles = Angles.Zero;
if ( !string.IsNullOrWhiteSpace( trackTarget ) && scene != null )
{
targetGo = scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, trackTarget, StringComparison.OrdinalIgnoreCase ) );
if ( targetGo != null )
{
startPos = targetGo.WorldPosition;
startAngles = targetGo.WorldRotation.Angles();
}
}
// Dispatch input
ToolOutcome<object> inputResult;
using ( Sandbox.Input.PlayerScope( (Sandbox.Input.PlayerIndex)playerIndex ) )
{
inputResult = await SyntheticInputDriver.SimulateInputAsync( action, state, durationMs );
}
// Wait physics step
await Task.Delay( 50 );
Vector3 endPos = Vector3.Zero;
Angles endAngles = Angles.Zero;
Vector3 displacement = Vector3.Zero;
Vector3 velocity = Vector3.Zero;
if ( targetGo != null && targetGo.IsValid() )
{
endPos = targetGo.WorldPosition;
endAngles = targetGo.WorldRotation.Angles();
displacement = endPos - startPos;
var rb = targetGo.GetComponent<Rigidbody>();
if ( rb != null ) velocity = rb.Velocity;
}
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = action,
DurationMs = durationMs,
PlayerIndex = playerIndex,
InputSuccess = inputResult.Success,
TrackedObject = targetGo?.Name,
StartPosition = startPos,
EndPosition = endPos,
Displacement = displacement,
DisplacementLength = displacement.Length,
FinalVelocity = velocity,
Success = true
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "gameplay_step.failed", "Gameplay stepping failed.", ex ) );
}
}
private static object? SafeRead( PropertyInfo p, object target )
{
try { return p.GetValue( target )?.ToString(); }
catch { return "<unreadable>"; }
}
private static object? ConvertJsonNode( JsonNode? node, Type targetType )
{
if ( node == null ) return null;
var str = node.ToString();
if ( targetType == typeof( string ) ) return str;
if ( targetType == typeof( bool ) ) return bool.TryParse( str, out var b ) && b;
if ( targetType == typeof( int ) ) return int.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var i ) ? i : 0;
if ( targetType == typeof( float ) ) return float.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var f ) ? f : 0f;
if ( targetType == typeof( double ) ) return double.TryParse( str, NumberStyles.Any, CultureInfo.InvariantCulture, out var d ) ? d : 0.0;
if ( targetType == typeof( Vector3 ) )
{
var parts = str.Split( ',' );
if ( parts.Length == 3 &&
float.TryParse( parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var x ) &&
float.TryParse( parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var y ) &&
float.TryParse( parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var z ) )
{
return new Vector3( x, y, z );
}
}
if ( targetType.IsEnum ) return Enum.Parse( targetType, str, true );
return Convert.ChangeType( str, targetType, CultureInfo.InvariantCulture );
}
}
}