Editor/Tools/ApiTools.cs

Editor toolset for live API discovery and exporting. It searches loaded types, returns type member signatures (properties, methods, enums), writes a full API dump file, and manages a cached editor-build version for regenerating the reference.

ReflectionFile Access
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using static Sandbox.Internal.GlobalToolsNamespace;
using SboxMcp.Registry;

namespace SboxMcp.Tools;

/// <summary>
/// Live API discovery over the whole s&box + game type surface. This is what
/// lets the AI do "anything": find the type it needs (PlayerController,
/// Rigidbody, a custom component...) and read its real members before using
/// component_add / component_set_property / code_write_file.
/// </summary>
public static class ApiTools
{
	[McpTool( "api_search", "Searches the entire s&box + project API by type name. Use this to discover components, systems and helpers to work with (e.g. 'player', 'rigidbody', 'sound').", ToolCategory.Editor )]
	public static object Search(
		[Desc( "Name substring, case-insensitive" )] string query,
		[Desc( "Only components you can add to a GameObject" )] bool componentsOnly = false,
		int max = 40 )
	{
		if ( string.IsNullOrWhiteSpace( query ) )
			throw new ArgumentException( "query must not be empty" );

		var types = EditorTypeLibrary.GetTypes()
			.Where( t => t.TargetType is not null )
			.Where( t => !componentsOnly || typeof( Sandbox.Component ).IsAssignableFrom( t.TargetType ) )
			.Where( t => (t.Name?.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false)
				|| (t.TargetType.FullName?.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false) )
			.OrderByDescending( t => string.Equals( t.Name, query, StringComparison.OrdinalIgnoreCase ) )
			.ThenBy( t => t.Name )
			.Take( max )
			.Select( t => new
			{
				name = t.Name,
				fullName = t.TargetType.FullName,
				isComponent = typeof( Sandbox.Component ).IsAssignableFrom( t.TargetType ),
				baseType = t.TargetType.BaseType?.Name,
				title = t.Title,
				group = t.Group
			} )
			.ToArray();

		return new { count = types.Length, note = "Use api_get_type for full members, or component_add for the component ones.", types };
	}

	[McpTool( "api_get_type", "Gets a type's public properties and methods with their real signatures - read this before setting properties or calling methods on a type.", ToolCategory.Editor )]
	public static object GetType(
		[Desc( "Type name or full name, e.g. 'PlayerController' or 'Sandbox.Rigidbody'" )] string typeName )
	{
		var all = EditorTypeLibrary.GetTypes().Where( t => t.TargetType is not null ).ToList();

		var desc = all.FirstOrDefault( t => string.Equals( t.TargetType.FullName, typeName, StringComparison.OrdinalIgnoreCase ) )
			?? all.FirstOrDefault( t => string.Equals( t.Name, typeName, StringComparison.OrdinalIgnoreCase ) )
			?? throw new InvalidOperationException( $"No type '{typeName}' - use api_search to find it" );

		var type = desc.TargetType;

		// enums: the useful thing is the list of valid values (what to pass to
		// component_set_property for an enum-typed field)
		if ( type.IsEnum )
		{
			return new
			{
				name = desc.Name,
				fullName = type.FullName,
				kind = "enum",
				values = Enum.GetNames( type ),
				note = "Pass one of these value names as a string."
			};
		}

		// properties INCLUDE inherited ones - component_set_property accepts
		// inherited members like Enabled/WorldPosition, so listing only declared
		// members would mislead. Methods stay declared-only to avoid Object noise.
		const BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
		const BindingFlags flags = propFlags | BindingFlags.DeclaredOnly;

		var properties = type.GetProperties( propFlags )
			.Where( p => p.GetIndexParameters().Length == 0 )
			.GroupBy( p => p.Name ).Select( g => g.First() ) // dedupe overridden/shadowed
			.Select( p =>
			{
				var pt = Nullable.GetUnderlyingType( p.PropertyType ) ?? p.PropertyType;
				var publicGet = p.GetMethod is { IsPublic: true };
				var publicSet = PublicSettable( p );
				return new
				{
					name = p.Name,
					type = FriendlyType( p.PropertyType ),
					// only report 'set' for a PUBLIC, non-init setter - a private or
					// init-only setter reports CanWrite=true but the compiler treats
					// it as read-only (e.g. PlayerController.Velocity)
					access = (publicGet ? "get" : "") + (publicSet ? " set" : ""),
					canWrite = publicSet,
					// inline enum options so the AI knows valid values to set
					enumValues = pt.IsEnum ? Enum.GetNames( pt ) : null
				};
			} )
			.OrderBy( p => p.name )
			.ToArray();

		var methods = type.GetMethods( flags )
			.Where( m => !m.IsSpecialName )
			.Select( m => new
			{
				name = m.Name,
				returns = FriendlyType( m.ReturnType ),
				parameters = m.GetParameters().Select( p => $"{FriendlyType( p.ParameterType )} {p.Name}" ).ToArray(),
				isStatic = m.IsStatic
			} )
			.OrderBy( m => m.name )
			.Take( 120 )
			.ToArray();

		return new
		{
			name = desc.Name,
			fullName = type.FullName,
			baseType = type.BaseType?.Name,
			isComponent = typeof( Sandbox.Component ).IsAssignableFrom( type ),
			isAbstract = type.IsAbstract,
			description = desc.Description,
			properties,
			methods
		};
	}

	/// <summary>Where the auto-maintained reference and its version marker live.</summary>
	const string ReferenceFile = ".sbox-mcp/api_surface.txt";
	const string VersionFile = ".sbox-mcp/api_version.txt";

	[McpTool( "api_version", "Reports the running editor's build version. The API reference auto-regenerates when this changes, so information is always current.", ToolCategory.Editor )]
	public static object Version()
	{
		var (key, display) = EngineBuild();
		var cached = ReadCachedVersion();

		return new
		{
			build = display,
			versionKey = key,
			referenceUpToDate = cached == key,
			cachedBuild = cached,
			note = cached == key
				? "The exported API reference matches this build."
				: "The API reference is stale or missing - call api_reference to refresh it."
		};
	}

	[McpTool( "api_reference", "Returns the path to a complete API reference for the CURRENT editor build, regenerating it automatically if the editor was updated since it was last written. This is the always-up-to-date full reference; api_search/api_get_type are the live per-query lookups.", ToolCategory.Editor, Writes = true )]
	public static object Reference(
		[Desc( "Force a fresh regeneration even if the build is unchanged" )] bool force = false )
	{
		var (key, display) = EngineBuild();
		var absoluteRef = AssetTools.ResolveInProject( ReferenceFile );
		var regenerated = false;

		if ( force || ReadCachedVersion() != key || !File.Exists( absoluteRef ) )
		{
			var (types, bytes) = WriteDump( absoluteRef, null, key, display );
			File.WriteAllText( AssetTools.ResolveInProject( VersionFile ), key );
			regenerated = true;

			return new { path = ReferenceFile, build = display, types, bytes, regenerated,
				note = "Regenerated for the current build. Read it with asset_read_raw or code_read_file." };
		}

		return new { path = ReferenceFile, build = display, regenerated,
			note = "Already current for this build. Read it with asset_read_raw or code_read_file." };
	}

	static string ReadCachedVersion()
	{
		try
		{
			var p = AssetTools.ResolveInProject( VersionFile );
			return File.Exists( p ) ? File.ReadAllText( p ).Trim() : null;
		}
		catch { return null; }
	}

	/// <summary>(stable key, human display) for the running editor build.</summary>
	static (string Key, string Display) EngineBuild()
	{
		// Application.Version / VersionDate are the real per-build identity;
		// the engine assembly version is static (1.0.1.0) so it can't be the key
		var version = Sandbox.Application.Version;
		DateTime date;
		try { date = Sandbox.Application.VersionDate; }
		catch { date = default; }

		if ( string.IsNullOrWhiteSpace( version ) )
			version = typeof( Sandbox.Component ).Assembly.GetName().Version?.ToString() ?? "unknown";

		var hasDate = date.Year > 2000;
		var key = hasDate ? $"{version}+{date:yyyyMMddHHmmss}" : version;
		var display = hasDate ? $"{version} ({date:yyyy-MM-dd HH:mm})" : version;
		return (key, display);
	}

	[McpTool( "api_dump", "Exports the ENTIRE live API surface (every public type and member from the loaded s&box + project assemblies) to a text file you choose. Always reflects the current installed build. Warning: large (~1MB). For the auto-maintained reference, prefer api_reference.", ToolCategory.Editor, Writes = true )]
	public static object Dump(
		[Desc( "Output path, e.g. 'api_surface.txt' (project-relative)" )] string path = "api_surface.txt",
		[Desc( "Only namespaces starting with this, e.g. 'Sandbox'; omit for everything" )] string namespaceFilter = null )
	{
		var (key, display) = EngineBuild();
		var absolute = AssetTools.ResolveInProject( path );
		var (types, bytes) = WriteDump( absolute, namespaceFilter, key, display );

		return new { written = path, build = display, types, bytes, note = "Complete current-build API reference." };
	}

	static (int Types, int Bytes) WriteDump( string absolutePath, string namespaceFilter, string versionKey, string versionDisplay )
	{
		var sb = new StringBuilder();
		sb.AppendLine( "# s&box API surface - LIVE reflection dump from the running editor" );
		sb.Append( "# build: " ).AppendLine( versionDisplay );
		sb.Append( "# versionKey: " ).AppendLine( versionKey );
		sb.AppendLine( "# format: TYPE <kind> <fullname>[ : base] then indented members (P property, M method, F field, E event)" );

		var assemblies = AppDomain.CurrentDomain.GetAssemblies()
			.Where( a => !a.IsDynamic )
			.Where( a =>
			{
				var n = a.GetName().Name ?? "";
				return n.StartsWith( "Sandbox", StringComparison.Ordinal )
					|| n.StartsWith( "package.", StringComparison.Ordinal )
					|| n.StartsWith( "Facepunch", StringComparison.Ordinal );
			} );

		var typeCount = 0;
		const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;

		foreach ( var type in assemblies.SelectMany( SafeTypes ).OrderBy( t => t.FullName, StringComparer.Ordinal ) )
		{
			if ( namespaceFilter is not null && !(type.Namespace?.StartsWith( namespaceFilter, StringComparison.OrdinalIgnoreCase ) ?? false) )
				continue;

			var kind = type.IsEnum ? "enum" : type.IsInterface ? "interface" : type.IsValueType ? "struct" : "class";
			var baseStr = type.BaseType is not null && type.BaseType != typeof( object ) && !type.IsEnum
				? $" : {type.BaseType.FullName}" : "";
			sb.Append( "TYPE " ).Append( kind ).Append( ' ' ).Append( type.FullName ).AppendLine( baseStr );
			typeCount++;

			foreach ( var p in type.GetProperties( flags ).Where( p => p.GetIndexParameters().Length == 0 ) )
				sb.Append( "  P " ).Append( FriendlyType( p.PropertyType ) ).Append( ' ' ).Append( p.Name )
					.Append( ' ' ).Append( p.GetMethod is { IsPublic: true } ? "get;" : "" ).AppendLine( PublicSettable( p ) ? "set;" : "" );

			foreach ( var m in type.GetMethods( flags ).Where( m => !m.IsSpecialName ) )
				sb.Append( "  M " ).Append( FriendlyType( m.ReturnType ) ).Append( ' ' ).Append( m.Name )
					.Append( '(' ).Append( string.Join( ", ", m.GetParameters().Select( x => $"{FriendlyType( x.ParameterType )} {x.Name}" ) ) ).AppendLine( ")" );

			foreach ( var f in type.GetFields( flags ).Where( f => !f.IsSpecialName ) )
				sb.Append( "  F " ).Append( FriendlyType( f.FieldType ) ).Append( ' ' ).AppendLine( f.Name );

			foreach ( var e in type.GetEvents( flags ) )
				sb.Append( "  E " ).Append( FriendlyType( e.EventHandlerType ) ).Append( ' ' ).AppendLine( e.Name );
		}

		Directory.CreateDirectory( Path.GetDirectoryName( absolutePath ) );
		File.WriteAllText( absolutePath, sb.ToString() );

		return (typeCount, sb.Length);
	}

	static IEnumerable<Type> SafeTypes( Assembly assembly )
	{
		try { return assembly.GetExportedTypes(); }
		catch { return Array.Empty<Type>(); }
	}

	/// <summary>True only for a PUBLIC, non-init setter - matches compiler
	/// writability. A private or init-only setter makes CanWrite true but the
	/// property isn't assignable in normal code (e.g. PlayerController.Velocity).</summary>
	static bool PublicSettable( PropertyInfo p )
	{
		if ( p.SetMethod is not { IsPublic: true } )
			return false;

		// init-only setters carry an IsExternalInit required modifier
		return !p.SetMethod.ReturnParameter.GetRequiredCustomModifiers()
			.Any( m => m.Name == "IsExternalInit" );
	}

	static string FriendlyType( Type t )
	{
		if ( t is null ) return "?";
		if ( t == typeof( void ) ) return "void";
		var underlying = Nullable.GetUnderlyingType( t );
		if ( underlying is not null ) return FriendlyType( underlying ) + "?";

		if ( t.IsGenericType )
		{
			var name = t.Name.Split( '`' )[0];
			var args = string.Join( ", ", t.GetGenericArguments().Select( FriendlyType ) );
			return $"{name}<{args}>";
		}

		return t.Name;
	}
}