Editor/Handlers/ComponentHandler.cs
using Sandbox;
using SboxMcp;
namespace SboxMcp.Handlers;
/// <summary>
/// Handles component-related commands: component.list, component.get,
/// component.set, component.add, component.remove.
/// </summary>
public static class ComponentHandler
{
/// <summary>
/// component.list — List all components on a GameObject.
/// Params: { "id": "guid-string" }
/// </summary>
public static Task<object> ListComponents( HandlerRequest request )
{
var go = ResolveGameObject( request );
var list = new List<object>();
foreach ( var comp in go.Components.GetAll() )
{
list.Add( new
{
type = comp.GetType().Name,
enabled = comp.Enabled,
} );
}
return Task.FromResult<object>( list );
}
/// <summary>
/// component.get — Get a specific component's properties by type name.
/// Params: { "id": "guid-string", "type": "TypeName" }
/// </summary>
public static Task<object> GetComponent( HandlerRequest request )
{
var go = ResolveGameObject( request );
var typeName = GetParam( request, "type" );
var comp = FindComponentByType( go, typeName );
var props = new Dictionary<string, object>();
try
{
var td = TypeLibrary.GetType( comp.GetType() );
if ( td is not null )
{
foreach ( var prop in td.Properties )
{
try { props[prop.Name] = prop.GetValue( comp )?.ToString() ?? ""; }
catch { props[prop.Name] = "<error>"; }
}
}
else
{
// Fall back to standard reflection if TypeLibrary returns null.
foreach ( var prop in comp.GetType().GetProperties() )
{
try { props[prop.Name] = prop.GetValue( comp )?.ToString() ?? ""; }
catch { props[prop.Name] = "<error>"; }
}
}
}
catch ( Exception ex )
{
props["_error"] = ex.Message;
}
return Task.FromResult<object>( (object)new
{
type = comp.GetType().Name,
enabled = comp.Enabled,
properties = props,
} );
}
/// <summary>
/// component.set — Set a property on a component.
/// Params: { "id": "guid", "type": "TypeName", "property": "PropName", "value": "value" }
/// </summary>
public static async Task<object> SetComponent( HandlerRequest request )
{
var go = ResolveGameObject( request );
var typeName = GetParam( request, "type" );
var propName = GetParam( request, "property" );
var rawValue = GetParam( request, "value" );
var comp = FindComponentByType( go, typeName );
var td = TypeLibrary.GetType( comp.GetType() );
if ( td is not null )
{
var prop = td.Properties.FirstOrDefault( p => p.Name == propName );
if ( prop is null )
throw new KeyNotFoundException( $"Property '{propName}' not found on {typeName}" );
var converted = await ConvertValueAsync( rawValue, prop.PropertyType );
prop.SetValue( comp, converted );
}
else
{
// Fall back to standard reflection.
var prop = comp.GetType().GetProperty( propName )
?? throw new KeyNotFoundException( $"Property '{propName}' not found on {typeName}" );
var converted = await ConvertValueAsync( rawValue, prop.PropertyType );
prop.SetValue( comp, converted );
}
EditorChanges.MarkDirty();
return (object)new { set = true, property = propName, value = rawValue };
}
/// <summary>
/// component.add — Add a component to a GameObject by type name.
/// Params: { "id": "guid", "type": "TypeName" }
/// </summary>
public static Task<object> AddComponent( HandlerRequest request )
{
var go = ResolveGameObject( request );
var typeName = GetParam( request, "type" );
// EditorTypeLibrary would catch editor-context types but it's only resolvable
// when Sandbox.Tools is linked. TypeLibrary (in Sandbox.System) covers
// the same types we care about and compiles in any context.
var typeDesc = TypeLibrary.GetType( typeName );
if ( typeDesc is null )
throw new TypeLoadException( $"Type not found: {typeName}" );
var comp = go.Components.Create( typeDesc );
Log.Info( $"[MCP] Added component {typeName} to {go.Name}" );
EditorChanges.MarkDirty();
return Task.FromResult<object>( (object)new
{
added = true,
type = comp.GetType().Name,
enabled = comp.Enabled,
} );
}
/// <summary>
/// component.remove — Remove a component by type name.
/// Params: { "id": "guid", "type": "TypeName" }
/// </summary>
public static Task<object> RemoveComponent( HandlerRequest request )
{
var go = ResolveGameObject( request );
var typeName = GetParam( request, "type" );
var comp = FindComponentByType( go, typeName );
comp.Destroy();
Log.Info( $"[MCP] Removed component {typeName} from {go.Name}" );
EditorChanges.MarkDirty();
return Task.FromResult<object>( (object)new { removed = true, type = typeName } );
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/// <summary>
/// Converts a raw string value to the target type, handling s&box resource types.
/// </summary>
private static async Task<object> ConvertValueAsync( string rawValue, Type targetType )
{
var typeName = targetType.Name;
// s&box resource types — must use their Load methods
if ( typeName == "Model" )
{
if ( rawValue.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) )
return Model.Load( rawValue );
// Cloud ident — fetch, mount, add to project refs, then load
var pkg = await Package.Fetch( rawValue, true );
if ( pkg is not null )
{
await pkg.MountAsync();
AssetHandler.AddPackageReference( rawValue );
var primary = pkg.GetMeta( "PrimaryAsset", "" );
if ( !string.IsNullOrEmpty( primary ) )
return Model.Load( primary );
}
throw new InvalidOperationException( $"Could not load cloud model: {rawValue}" );
}
if ( typeName == "Material" )
{
if ( rawValue.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) )
return Material.Load( rawValue );
var pkg = await Package.Fetch( rawValue, true );
if ( pkg is not null )
{
await pkg.MountAsync();
AssetHandler.AddPackageReference( rawValue );
var primary = pkg.GetMeta( "PrimaryAsset", "" );
if ( !string.IsNullOrEmpty( primary ) )
return Material.Load( primary );
}
throw new InvalidOperationException( $"Could not load cloud material: {rawValue}" );
}
if ( typeName == "Color" )
return Color.Parse( rawValue ) ?? Color.White;
if ( typeName == "Vector3" )
{
var parts = rawValue.Split( ',' );
if ( parts.Length == 3 )
return new Vector3( float.Parse( parts[0].Trim() ), float.Parse( parts[1].Trim() ), float.Parse( parts[2].Trim() ) );
}
if ( typeName == "Angles" )
{
var parts = rawValue.Split( ',' );
if ( parts.Length == 3 )
return new Angles( float.Parse( parts[0].Trim() ), float.Parse( parts[1].Trim() ), float.Parse( parts[2].Trim() ) );
}
if ( targetType == typeof( bool ) )
return bool.Parse( rawValue );
if ( targetType == typeof( float ) )
return float.Parse( rawValue );
if ( targetType == typeof( int ) )
return int.Parse( rawValue );
return Convert.ChangeType( rawValue, targetType );
}
private static GameObject ResolveGameObject( HandlerRequest request )
{
var id = GetParam( request, "id" );
if ( !Guid.TryParse( id, out var guid ) )
throw new ArgumentException( $"Invalid GUID: {id}" );
return SceneHandler.FindObjectById( guid )
?? throw new KeyNotFoundException( $"GameObject not found: {id}" );
}
private static Component FindComponentByType( GameObject go, string typeName )
{
foreach ( var comp in go.Components.GetAll() )
{
if ( comp.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) )
return comp;
}
throw new KeyNotFoundException( $"Component '{typeName}' not found on '{go.Name}'" );
}
private static string GetParam( HandlerRequest request, string key )
{
if ( request.Params is JsonElement el && el.TryGetProperty( key, out var prop ) )
{
var val = prop.GetString();
if ( val is not null ) return val;
}
throw new ArgumentException( $"Missing required parameter: {key}" );
}
}