🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (48 total matches found)
notpointless.chomnr_mcp / Editor/Registry/McpToolAttribute.cs
Editor library
using System;

namespace SboxMcp.Registry;

public enum ToolCategory
{
	Scene,
	GameObject,
	Component,
	Prefab,
	Asset,
	ModelDoc,
	AnimGraph,
	ShaderGraph,
	ActionGraph,
	Code,
	Editor,
	Retargeter,
	AnimEditor,
	Cloud,
	Imported
}

/// <summary>
/// Marks a static method as an MCP tool. The registry reflects the method's
/// parameters into a JSON Schema and exposes it via tools/list.
/// </summary>
[AttributeUsage( AttributeTargets.Method )]
public sealed class McpToolAttribute : Attribute
{
	public string Name { get; }
	public string Description { get; }
	public ToolCategory Category { get; }

	/// <summary>Write tools are subject to the permission gate (approve-writes / read-only modes).</summary>
	public bool Writes { get; init; }

	/// <summary>
	/// Optional requirement key (e.g. an integration's library ident). The host
	/// resolves it via ToolRegistry.RequirementResolver; unresolved tools are
	/// hidden from clients and shown disabled in the tool browser.
	/// </summary>
	public string Requires { get; init; }

	/// <summary>
	/// Ships disabled; the user must enable it in the tool browser. Used for
	/// tools with external effects (e.g. downloading cloud assets).
	/// </summary>
	public bool DisabledByDefault { get; init; }

	public McpToolAttribute( string name, string description, ToolCategory category )
	{
		Name = name;
		Description = description;
		Category = category;
	}
}

/// <summary>
/// Optional description for a tool parameter, surfaced in the JSON Schema.
/// </summary>
[AttributeUsage( AttributeTargets.Parameter )]
public sealed class DescAttribute : Attribute
{
	public string Text { get; }
	public DescAttribute( string text ) { Text = text; }
}

/// <summary>
/// Thrown when tool arguments are missing or cannot be bound; surfaced to the
/// MCP client as an isError tool result.
/// </summary>
public sealed class ToolArgumentException : Exception
{
	public ToolArgumentException( string message, Exception inner = null ) : base( message, inner ) { }
}
notpointless.chomnr_mcp / Editor/Registry/ToolRegistry.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using SboxMcp.Server;

namespace SboxMcp.Registry;

/// <summary>
/// A discovered [McpTool] method, with its generated descriptor and an
/// argument-binding invoker.
/// </summary>
public sealed class RegisteredTool
{
	public McpToolAttribute Meta { get; }
	public MethodInfo Method { get; }
	public McpToolDescriptor Descriptor { get; }

	/// <summary>
	/// Why this tool cannot run right now ("Disabled", "Not Installed", ...),
	/// or null when it is available. Evaluated live so user toggles and
	/// integrations installed mid-session apply without a restart.
	/// </summary>
	public string UnavailableReason
	{
		get
		{
			if ( ToolRegistry.DisabledResolver?.Invoke( this ) ?? Meta.DisabledByDefault )
				return "Disabled";

			return Meta.Requires is null ? null : ToolRegistry.RequirementResolver?.Invoke( Meta.Requires );
		}
	}

	public bool IsAvailable => UnavailableReason is null;

	internal RegisteredTool( McpToolAttribute meta, MethodInfo method )
	{
		Meta = meta;
		Method = method;
		Descriptor = new McpToolDescriptor( meta.Name, BuildDescription( meta ), SchemaGenerator.ForMethod( method ) );
	}

	static string BuildDescription( McpToolAttribute meta ) =>
		meta.Writes ? $"{meta.Description} (modifies project state)" : meta.Description;

	/// <summary>
	/// Binds JSON arguments to the method's parameters by name and invokes it.
	/// Throws ToolArgumentException on missing/unbindable arguments.
	/// </summary>
	public object Invoke( JsonElement? args )
	{
		var parameters = Method.GetParameters();
		var bound = new object[parameters.Length];

		for ( var i = 0; i < parameters.Length; i++ )
		{
			var p = parameters[i];

			// JsonElement params accept explicit null (e.g. to clear a reference
			// property); for typed params null falls through to the default
			if ( args is { ValueKind: JsonValueKind.Object } a && a.TryGetProperty( p.Name, out var value )
				&& (value.ValueKind != JsonValueKind.Null || p.ParameterType == typeof( JsonElement )) )
			{
				try
				{
					bound[i] = p.ParameterType == typeof( JsonElement )
						? value.Clone()
						: value.Deserialize( p.ParameterType, ToolRegistry.BindOptions );
				}
				catch ( Exception e ) when ( e is JsonException or NotSupportedException )
				{
					throw new ToolArgumentException(
						$"Argument '{p.Name}' could not be read as {p.ParameterType.Name}: {e.Message}", e );
				}
			}
			else if ( p.HasDefaultValue )
			{
				bound[i] = p.DefaultValue;
			}
			else
			{
				throw new ToolArgumentException( $"Missing required argument '{p.Name}'" );
			}
		}

		try
		{
			return Method.Invoke( null, bound );
		}
		catch ( TargetInvocationException e ) when ( e.InnerException is not null )
		{
			throw e.InnerException;
		}
	}
}

/// <summary>
/// Discovers [McpTool] static methods and serves them to the MCP server.
/// </summary>
public sealed class ToolRegistry
{
	/// <summary>
	/// Maps a tool's Requires key to an unavailability reason (short, e.g.
	/// "Not Installed") or null when the requirement is satisfied. Null
	/// resolver = everything available.
	/// </summary>
	public static Func<string, string> RequirementResolver { get; set; }

	/// <summary>
	/// Whether the user has disabled this tool. Null resolver = only
	/// DisabledByDefault applies.
	/// </summary>
	public static Func<RegisteredTool, bool> DisabledResolver { get; set; }

	internal static readonly JsonSerializerOptions BindOptions = new()
	{
		PropertyNameCaseInsensitive = true,
		Converters = { new JsonStringEnumConverter() }
	};

	static readonly JsonSerializerOptions ResultOptions = new()
	{
		WriteIndented = true,
		Converters = { new JsonStringEnumConverter() }
	};

	readonly List<RegisteredTool> _tools = new();
	readonly Dictionary<string, RegisteredTool> _byName = new( StringComparer.Ordinal );

	public IReadOnlyList<RegisteredTool> Tools => _tools;

	public void AddAssembly( Assembly assembly )
	{
		var methods = assembly.GetTypes()
			.Where( t => t.IsClass )
			.SelectMany( t => t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )
			.Select( m => (Method: m, Meta: m.GetCustomAttribute<McpToolAttribute>()) )
			.Where( x => x.Meta is not null )
			.OrderBy( x => x.Meta.Name, StringComparer.Ordinal );

		foreach ( var (method, meta) in methods )
		{
			if ( _byName.ContainsKey( meta.Name ) )
				continue;

			var tool = new RegisteredTool( meta, method );
			_tools.Add( tool );
			_byName[meta.Name] = tool;
		}
	}

	public RegisteredTool Find( string name ) => _byName.GetValueOrDefault( name );

	/// <summary>
	/// Registers an arbitrary public static method (from another library) as a
	/// tool. Returns null when the name is already taken.
	/// </summary>
	public RegisteredTool AddImported( string name, string description, ToolCategory category, MethodInfo method )
	{
		if ( _byName.ContainsKey( name ) )
			return null;

		var meta = new McpToolAttribute( name, description, category ) { Writes = true };
		var tool = new RegisteredTool( meta, method );
		_tools.Add( tool );
		_byName[name] = tool;
		return tool;
	}

	public void Remove( string name )
	{
		if ( _byName.Remove( name, out var tool ) )
			_tools.Remove( tool );
	}

	/// <summary>
	/// Converts a tool's return value to the text sent back to the client.
	/// </summary>
	public static string FormatResult( object result ) => result switch
	{
		null => """{ "ok": true }""",
		string s => s,
		_ => JsonSerializer.Serialize( result, ResultOptions )
	};
}
notpointless.chomnr_mcp / Editor/Tools/PrefabTools.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

public static class PrefabTools
{
	[McpTool( "prefab_instantiate", "Instantiates a prefab into the active scene.", ToolCategory.Prefab, Writes = true )]
	public static object Instantiate(
		[Desc( "Prefab asset path, e.g. 'prefabs/door.prefab'" )] string prefabPath,
		[Desc( "World position [x, y, z]" )] float[] position = null )
	{
		var session = RequireSession();

		var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )
			?? throw new InvalidOperationException( $"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'" );

		var prefabScene = SceneUtility.GetPrefabScene( prefabFile )
			?? throw new InvalidOperationException( $"Prefab '{prefabPath}' could not be loaded" );

		using var undo = session.UndoScope( $"MCP: instantiate {prefabPath}" ).WithGameObjectCreations().Push();

		var transform = position is null
			? global::Transform.Zero
			: new Transform( ToVector3( position, "position" ) );

		var instance = prefabScene.Clone( transform );
		return Describe( instance );
	}

	[McpTool( "prefab_instantiate_many", "Instantiates a prefab at many world positions in one call - populate a level efficiently (a forest of trees, a row of enemies, scattered pickups). Returns the created instance ids.", ToolCategory.Prefab, Writes = true )]
	public static object InstantiateMany(
		[Desc( "Prefab asset path, e.g. 'prefabs/tree.prefab'" )] string prefabPath,
		[Desc( "World positions, each [x, y, z]" )] float[][] positions )
	{
		if ( positions is null || positions.Length == 0 )
			throw new ArgumentException( "Pass at least one position" );

		var session = RequireSession();

		var prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )
			?? throw new InvalidOperationException( $"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'" );

		var prefabScene = SceneUtility.GetPrefabScene( prefabFile )
			?? throw new InvalidOperationException( $"Prefab '{prefabPath}' could not be loaded" );

		using var undo = session.UndoScope( $"MCP: instantiate {positions.Length}x {prefabPath}" ).WithGameObjectCreations().Push();

		var instances = new List<object>();
		foreach ( var pos in positions )
		{
			var instance = prefabScene.Clone( new Transform( ToVector3( pos, "position" ) ) );
			instances.Add( new { id = instance.Id, name = instance.Name, position = pos } );
		}

		return new { prefab = prefabPath, count = instances.Count, instances };
	}

	[McpTool( "prefab_create_from_gameobject", "Turns a GameObject (and its children) into a reusable .prefab asset; the original becomes an instance of it.", ToolCategory.Prefab, Writes = true )]
	public static object CreateFromGameObject(
		[Desc( "GameObject id or unique name" )] string gameObject,
		[Desc( "Output path ending in .prefab, e.g. 'prefabs/door.prefab'" )] string prefabPath )
	{
		if ( !prefabPath.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
			throw new ArgumentException( "prefabPath must end in .prefab" );

		var session = RequireSession();
		var go = FindGameObject( gameObject );
		var absolute = AssetTools.ResolveNewAssetPath( prefabPath );

		if ( System.IO.File.Exists( absolute ) )
			throw new InvalidOperationException( $"'{prefabPath}' already exists" );

		System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );

		using var undo = session.UndoScope( $"MCP: create prefab {prefabPath}" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();

		EditorUtility.Prefabs.ConvertGameObjectToPrefab( go, absolute );

		return new { created = prefabPath, instanceId = go.Id };
	}

	[McpTool( "prefab_break_instance", "Unlinks a prefab instance so it becomes plain GameObjects.", ToolCategory.Prefab, Writes = true )]
	public static object BreakInstance( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		if ( !go.IsPrefabInstance )
			throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );

		using var undo = session.UndoScope( "MCP: break prefab instance" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();

		go.BreakFromPrefab();
		return Describe( go );
	}

	[McpTool( "prefab_update_from_prefab", "Re-syncs a prefab instance from its source prefab file.", ToolCategory.Prefab, Writes = true )]
	public static object UpdateFromPrefab( [Desc( "GameObject id or unique name of the prefab instance root" )] string gameObject )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		if ( !go.IsPrefabInstance )
			throw new InvalidOperationException( $"'{go.Name}' is not a prefab instance" );

		using var undo = session.UndoScope( "MCP: update from prefab" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();

		go.UpdateFromPrefab();
		return Describe( go );
	}
}
notpointless.chomnr_mcp / Editor/Tools/ServerTools.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using SboxMcp.Integration;
using SboxMcp.Registry;

namespace SboxMcp.Tools;

/// <summary>
/// Tools that operate on the MCP server itself: batch execution (many calls in
/// one request) and reading/adjusting the server's own configuration.
/// </summary>
public static class ServerTools
{
	[McpTool( "batch", "Runs several tool calls in one request, in order - big speedup for multi-step builds (create object, add component, set properties...). Each step is {name, arguments}. Stops on the first error unless continueOnError is true.", ToolCategory.Editor, Writes = true )]
	public static object Batch(
		[Desc( "JSON array of steps, e.g. [{\"name\":\"gameobject_create\",\"arguments\":{\"name\":\"X\"}}, ...]" )] JsonElement steps,
		[Desc( "Keep going after a step fails instead of stopping" )] bool continueOnError = false )
	{
		if ( steps.ValueKind != JsonValueKind.Array )
			throw new ArgumentException( "steps must be a JSON array of {name, arguments} objects" );

		var registry = McpHost.Registry
			?? throw new InvalidOperationException( "Server not initialized" );

		var results = new List<object>();
		var index = 0;

		foreach ( var step in steps.EnumerateArray() )
		{
			index++;

			if ( !step.TryGetProperty( "name", out var nameEl ) || nameEl.ValueKind != JsonValueKind.String )
			{
				results.Add( new { step = index, ok = false, error = "step is missing a string 'name'" } );
				if ( !continueOnError ) break; else continue;
			}

			var name = nameEl.GetString();
			var tool = registry.Find( name );

			if ( tool is null || !tool.IsAvailable )
			{
				results.Add( new { step = index, name, ok = false, error = tool is null ? "unknown tool" : $"unavailable: {tool.UnavailableReason}" } );
				if ( !continueOnError ) break; else continue;
			}

			JsonElement? args = step.TryGetProperty( "arguments", out var a ) && a.ValueKind == JsonValueKind.Object ? a : null;

			try
			{
				// already on the editor main thread (batch itself was dispatched there)
				var result = tool.Invoke( args );

				// async tools (cloud_*) return a Task - can't be awaited on the
				// main thread without freezing the editor, so reject clearly
				if ( result is System.Threading.Tasks.Task )
				{
					results.Add( new { step = index, name, ok = false, error = "this tool is async and cannot run inside a batch - call it on its own" } );
					LogStep( tool, args, true, "async tool skipped" );
					if ( !continueOnError ) break; else continue;
				}

				results.Add( new { step = index, name, ok = true, result } );
				LogStep( tool, args, false, null );
			}
			catch ( Exception e )
			{
				results.Add( new { step = index, name, ok = false, error = e.Message } );
				LogStep( tool, args, false, e.Message );
				if ( !continueOnError ) break;
			}
		}

		var ran = results.Count;
		var failed = results.Count( r => r.GetType().GetProperty( "ok" )?.GetValue( r ) is false );
		return new { requested = steps.GetArrayLength(), ran, failed, results };
	}

	// each batch step gets its own activity-feed entry (so revert/audit work per-step)
	static void LogStep( RegisteredTool tool, JsonElement? args, bool skipped, string error )
	{
		ActivityLog.Record( new ActivityRecord
		{
			ToolName = $"batch:{tool.Meta.Name}",
			Category = tool.Meta.Category,
			ArgsDigest = PermissionGate.Summarize( args ),
			Ok = error is null && !skipped,
			Error = error
		} );
	}

	[McpTool( "server_get_config", "Reads the MCP server's current configuration: port, permission mode, autostart, tool counts.", ToolCategory.Editor )]
	public static object GetConfig()
	{
		var registry = McpHost.Registry;
		var tools = registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>();

		return new
		{
			url = McpHost.Server?.Url,
			running = McpHost.Server?.IsRunning ?? false,
			port = McpSettings.Port,
			autoStart = McpSettings.AutoStart,
			permissionMode = McpSettings.Mode.ToString(),
			toolCount = tools.Count,
			enabledTools = tools.Count( t => t.IsAvailable ),
			connectedClients = McpHost.Server?.Sessions.Count ?? 0,
			note = "Permission mode is set by the user in the dashboard and cannot be changed over MCP by design."
		};
	}

	[McpTool( "server_set_config", "Adjusts server settings the AI is allowed to change (port, autostart). Permission mode stays user-only. Changing the port restarts the listener.", ToolCategory.Editor, Writes = true )]
	public static object SetConfig(
		[Desc( "New port 1024-65535; omit to leave unchanged" )] int? port = null,
		[Desc( "Autostart on editor load; omit to leave unchanged" )] bool? autoStart = null )
	{
		var restarted = false;

		if ( autoStart is bool a )
			McpSettings.AutoStart = a;

		if ( port is int p )
		{
			if ( p is < 1024 or > 65535 )
				throw new ArgumentException( "port must be 1024..65535" );

			if ( p != McpSettings.Port )
			{
				McpSettings.Port = p;
				McpHost.Restart();
				restarted = true;
			}
		}

		return new { port = McpSettings.Port, autoStart = McpSettings.AutoStart, restarted, note = restarted ? "Listener restarted on the new port - reconnect your client." : "Updated." };
	}
}
notpointless.chomnr_mcp / Editor/UI/ImportToolsDialog.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;
using SboxMcp.Integration;

namespace SboxMcp.UI;

/// <summary>
/// Pick public static methods from installed libraries (and other loaded
/// code) to expose as MCP tools. Searchable; libraries are listed separately
/// from everything else. Choices apply immediately and persist.
/// </summary>
public class ImportToolsDialog : Dialog
{
	readonly LineEdit _search;
	readonly ScrollArea _scroll;

	public ImportToolsDialog( Widget parent ) : base( parent )
	{
		Window.WindowTitle = "Import Tools From Library";
		Window.SetWindowIcon( "library_add" );
		Window.SetModal( true, true );
		Window.MinimumWidth = 560;
		Window.MinimumHeight = 480;

		Layout = Layout.Column();
		Layout.Margin = 16;
		Layout.Spacing = 8;

		var hint = Layout.Add( new Label(
			"Expose public static methods from installed libraries as MCP tools. "
			+ "Imported tools persist, re-bind every session, and are write-gated by approvals.", this ) );
		hint.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px;" );
		hint.WordWrap = true;

		_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Search methods, types or libraries..." } );
		_search.TextEdited += _ => Rebuild();

		_scroll = new ScrollArea( this );
		_scroll.Canvas = new Widget( _scroll );
		_scroll.Canvas.Layout = Layout.Column();
		_scroll.Canvas.Layout.Spacing = 2;
		_scroll.Canvas.Layout.Margin = 4;
		_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
		_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
		Layout.Add( _scroll, 1 );

		var buttons = Layout.AddRow();
		buttons.AddStretchCell();
		var done = buttons.Add( new Button.Primary( "Done" ) { Icon = "check" } );
		done.Clicked = Close; // Dialog.Close closes the host window (Destroy leaves it black)

		Rebuild();
	}

	void Rebuild()
	{
		var canvas = _scroll.Canvas;
		canvas.Layout.Clear( true );

		var query = _search.Text;
		var candidates = ToolImporter.CandidateAssemblies().ToList();

		AddSection( canvas, "Libraries", "extension",
			candidates.Where( ToolImporter.IsLibraryAssembly ).ToList(), query );

		AddSection( canvas, "Project & Other", "folder",
			candidates.Where( a => !ToolImporter.IsLibraryAssembly( a ) ).ToList(), query );

		canvas.Layout.AddStretchCell();
	}

	void AddSection( Widget canvas, string title, string icon, List<Assembly> assemblies, string query )
	{
		var header = canvas.Layout.Add( new Label( title, canvas ) );
		header.SetStyles( $"color: {Theme.Blue.Hex}; font-size: 12px; font-weight: 700; margin-top: 8px;" );

		var any = false;

		foreach ( var assembly in assemblies )
		{
			var methods = ToolImporter.CandidateMethods( assembly )
				.Where( m => Matches( assembly, m, query ) )
				.Take( 60 )
				.ToList();

			if ( methods.Count == 0 )
				continue;

			any = true;

			var name = canvas.Layout.Add( new Label( ToolImporter.FriendlyName( assembly ), canvas ) );
			name.SetStyles( $"color: {Theme.Text.Hex}; font-size: 11px; font-weight: 600; margin-top: 4px; margin-left: 6px;" );

			foreach ( var method in methods )
			{
				var parameters = string.Join( ", ", method.GetParameters().Select( p => p.Name ) );
				var check = canvas.Layout.Add( new Checkbox( $"{method.DeclaringType?.Name}.{method.Name}({parameters})", canvas )
				{
					Value = ToolImporter.IsImported( method )
				} );
				check.ToolTip = method.DeclaringType?.FullName;

				var captured = method;
				check.Clicked = () =>
				{
					if ( check.Value )
						ToolImporter.Import( captured );
					else
						ToolImporter.Unimport( captured );
				};
			}
		}

		if ( !any )
		{
			var empty = canvas.Layout.Add( new Label(
				string.IsNullOrWhiteSpace( query ) ? "Nothing importable found." : "No matches.", canvas ) );
			empty.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px; margin-left: 6px;" );
		}
	}

	static bool Matches( Assembly assembly, MethodInfo method, string query )
	{
		if ( string.IsNullOrWhiteSpace( query ) )
			return true;

		return method.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
			|| (method.DeclaringType?.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false)
			|| ToolImporter.FriendlyName( assembly ).Contains( query, StringComparison.OrdinalIgnoreCase );
	}
}
notpointless.chomnr_mcp / Editor/UI/Pages/ToolsPage.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Integration;
using SboxMcp.Registry;

namespace SboxMcp.UI;

/// <summary>
/// Searchable, category-filterable browser of every tool the server exposes.
/// Doubles as documentation.
/// </summary>
public class ToolsPage : Widget
{
	readonly LineEdit _search;
	readonly List<CategoryChip> _chips = new();
	readonly ScrollArea _scroll;

	int _builtSignature = -1;

	public ToolsPage( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		var searchRow = Layout.AddRow();
		searchRow.Spacing = 6;

		_search = searchRow.Add( new LineEdit( this ) { PlaceholderText = "Search tools..." }, 1 );
		_search.TextEdited += _ => Rebuild();

		var import = searchRow.Add( new Button( "Import Tools", "library_add" ) );
		import.ToolTip = "Expose public static methods from other installed libraries as MCP tools";
		import.Clicked = () => new ImportToolsDialog( this ).Show();

		// FlowRow wraps the chips to new lines on narrow docks instead of
		// letting them overlap
		var chipFlow = Layout.Add( new FlowRow( this ) );

		foreach ( var category in Enum.GetValues<ToolCategory>() )
		{
			var chip = new CategoryChip( category, chipFlow, clickable: true );
			chip.OnToggled = Rebuild;
			_chips.Add( chip );
			chipFlow.AddItem( chip );
		}

		_scroll = new ScrollArea( this );
		_scroll.Canvas = new Widget( _scroll );
		_scroll.Canvas.Layout = Layout.Column();
		_scroll.Canvas.Layout.Spacing = 2;
		_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
		_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
		Layout.Add( _scroll, 1 );

		Rebuild();
	}

	/// <summary>
	/// The dock restores before McpHost initializes, so the registry is empty
	/// at construction time - poll until tools appear.
	/// </summary>
	public void Tick()
	{
		var sig = Signature();
		if ( sig == _builtSignature )
			return;

		Rebuild();
	}

	static int Signature()
	{
		var tools = McpHost.Registry?.Tools;
		return tools is null ? 0 : tools.Count * 1000 + tools.Count( t => t.IsAvailable );
	}

	void Rebuild()
	{
		_builtSignature = Signature();

		var canvas = _scroll.Canvas;
		canvas.Layout.Clear( true );

		var query = _search.Text;
		var enabled = _chips.Where( c => c.Toggled ).Select( c => c.Category ).ToHashSet();

		var tools = (McpHost.Registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>())
			.Where( t => enabled.Contains( t.Meta.Category ) )
			.Where( t => string.IsNullOrWhiteSpace( query )
				|| t.Meta.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
				|| t.Meta.Description.Contains( query, StringComparison.OrdinalIgnoreCase ) )
			.ToList();

		var count = canvas.Layout.Add( new Label( $"{tools.Count} tools", canvas ) );
		count.SetStyles( $"color: {Palette.TextDim.Hex}; font-size: 10px;" );

		foreach ( var tool in tools )
			canvas.Layout.Add( new ToolRow( tool, canvas ) );

		canvas.Layout.AddStretchCell();
	}
}

/// <summary>
/// One tool entry: name (mono), write badge, wrapped description.
/// </summary>
public class ToolRow : Widget
{
	const float ToggleWidth = 40;

	readonly RegisteredTool _tool;

	public ToolRow( RegisteredTool tool, Widget parent ) : base( parent )
	{
		_tool = tool;
		FixedHeight = 40;
		ToolTip = tool.Meta.Description + "\n\nClick the toggle to enable/disable this tool.";
	}

	bool UserDisabled => McpSettings.GetToolDisabledOverride( _tool.Meta.Name ) ?? _tool.Meta.DisabledByDefault;

	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );

		if ( e.RightMouseButton )
			return;

		// the toggle lives in the right strip of the row
		if ( e.LocalPosition.x < LocalRect.Right - ToggleWidth )
			return;

		McpSettings.SetToolDisabled( _tool.Meta.Name, !UserDisabled );
		Update();
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();

		var unavailable = _tool.UnavailableReason;
		var disabled = unavailable is not null;
		var accent = Palette.For( _tool.Meta.Category );

		if ( disabled )
			accent = accent.WithAlpha( 0.35f );

		if ( Paint.HasMouseOver && !disabled )
		{
			Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
			Paint.DrawRect( LocalRect, 5 );
		}

		// category color tick
		Paint.SetBrush( accent );
		Paint.DrawRect( new Rect( LocalRect.Left + 2, LocalRect.Top + 8, 3, LocalRect.Height - 16 ), 1.5f );

		// name
		Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.6f ) : Palette.TextBright );
		Paint.SetFont( "Consolas", 8, 600 );
		var nameWidth = Paint.MeasureText( _tool.Meta.Name ).x;
		Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 4, nameWidth + 4, 14 ), _tool.Meta.Name, TextFlag.LeftCenter );

		var badgeLeft = LocalRect.Left + 20 + nameWidth;

		// writes badge
		if ( _tool.Meta.Writes && !disabled )
		{
			var badge = new Rect( badgeLeft, LocalRect.Top + 5, 44, 13 );
			Paint.SetBrush( Palette.Error.WithAlpha( 0.18f ) );
			Paint.DrawRect( badge, 6 );
			Paint.SetPen( Palette.Error );
			Paint.SetDefaultFont( 6, 700 );
			Paint.DrawText( badge, "WRITES", TextFlag.Center );
		}

		// unavailable badge, e.g. "Not Installed"
		if ( disabled )
		{
			Paint.SetDefaultFont( 6, 700 );
			var badgeWidth = Paint.MeasureText( unavailable ).x + 12;
			var badge = new Rect( badgeLeft, LocalRect.Top + 5, badgeWidth, 13 );
			Paint.SetBrush( Palette.TextDim.WithAlpha( 0.15f ) );
			Paint.DrawRect( badge, 6 );
			Paint.SetPen( Palette.TextDim );
			Paint.DrawText( badge, unavailable, TextFlag.Center );
		}

		// description
		Paint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.5f ) : Palette.TextDim );
		Paint.SetDefaultFont( 7 );
		Paint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 20, LocalRect.Width - ToggleWidth - 20, 14 ),
			_tool.Meta.Description, TextFlag.LeftCenter | TextFlag.SingleLine );

		// enable/disable toggle (persisted per tool)
		var off = UserDisabled;
		Paint.SetPen( off ? Palette.TextDim : Theme.Green );
		Paint.DrawIcon( new Rect( LocalRect.Right - ToggleWidth, LocalRect.Top, ToggleWidth - 8, LocalRect.Height ),
			off ? "toggle_off" : "toggle_on", 22, TextFlag.Center );
	}
}
notpointless.chomnr_mcp / Editor/Integration/LogCapture.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Sandbox;

namespace SboxMcp.Integration;

public sealed class CapturedLog
{
	public long Seq { get; init; }
	public DateTime Time { get; init; } = DateTime.Now;
	public string Level { get; init; }
	public string Logger { get; init; }
	public string Message { get; init; }
	public string Stack { get; init; }
	public bool IsDiagnostic { get; init; }
}

/// <summary>
/// Subscribes to the engine log stream so tools can read recent console
/// output (including compile diagnostics, which the editor logs). Each entry
/// gets a monotonic sequence number so callers can poll incrementally with a
/// "since" cursor instead of re-reading old entries.
/// </summary>
public static class LogCapture
{
	const int Capacity = 4000;

	static readonly LinkedList<CapturedLog> _logs = new();
	static long _nextSeq;
	static bool _hooked;

	public static void Start()
	{
		if ( _hooked )
			return;

		_hooked = true;
		Editor.EditorUtility.AddLogger( OnMessage );
	}

	public static void Stop()
	{
		if ( !_hooked )
			return;

		_hooked = false;
		Editor.EditorUtility.RemoveLogger( OnMessage );
	}

	/// <summary>The sequence number of the newest captured entry (0 if none).
	/// Pass it back as `sinceSeq` next call to get only what's new.</summary>
	public static long LatestSeq
	{
		get { lock ( _logs ) return _nextSeq; }
	}

	static void OnMessage( LogEvent ev )
	{
		lock ( _logs )
		{
			var entry = new CapturedLog
			{
				Seq = ++_nextSeq,
				Level = ev.Level.ToString(),
				Logger = ev.Logger,
				Message = ev.Message,
				Stack = ev.Stack,
				IsDiagnostic = ev.IsDiagnostic
			};

			_logs.AddFirst( entry );
			while ( _logs.Count > Capacity )
				_logs.RemoveLast();
		}
	}

	/// <summary>Newest-first recent entries, optionally only those newer than
	/// <paramref name="sinceSeq"/> (the incremental cursor).</summary>
	public static IReadOnlyList<CapturedLog> Recent( int count, string minLevel = null, bool diagnosticsOnly = false, long sinceSeq = 0 )
	{
		var threshold = Rank( minLevel );

		lock ( _logs )
		{
			return _logs
				.Where( l => l.Seq > sinceSeq )
				.Where( l => Rank( l.Level ) >= threshold )
				.Where( l => !diagnosticsOnly || l.IsDiagnostic )
				.Take( count )
				.ToArray();
		}
	}

	/// <summary>Regex/severity/time-filtered search over the buffer.</summary>
	public static IReadOnlyList<CapturedLog> Search( string pattern, string minLevel, int max, DateTime? since )
	{
		var threshold = Rank( minLevel );
		Regex rx = string.IsNullOrEmpty( pattern ) ? null : new Regex( pattern, RegexOptions.IgnoreCase );

		lock ( _logs )
		{
			return _logs
				.Where( l => Rank( l.Level ) >= threshold )
				.Where( l => since is null || l.Time >= since.Value )
				.Where( l => rx is null || (l.Message is not null && rx.IsMatch( l.Message )) )
				.Take( max )
				.ToArray();
		}
	}

	public static void Clear()
	{
		lock ( _logs ) _logs.Clear();
	}

	static int Rank( string level ) => level?.ToLowerInvariant() switch
	{
		"error" => 4,
		"warn" or "warning" => 3,
		"info" => 2,
		"debug" or "trace" => 1,
		_ => 0
	};
}
notpointless.chomnr_mcp / Editor/Registry/SchemaGenerator.cs
Editor library
using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace SboxMcp.Registry;

/// <summary>
/// Reflects a tool method's parameters into a JSON Schema object.
/// </summary>
public static class SchemaGenerator
{
	public static JsonElement ForMethod( MethodInfo method )
	{
		var properties = new JsonObject();
		var required = new JsonArray();

		foreach ( var p in method.GetParameters() )
		{
			var prop = ForType( p.ParameterType );

			var desc = p.GetCustomAttribute<DescAttribute>()?.Text;
			if ( desc is not null )
				prop["description"] = desc;

			if ( p.HasDefaultValue )
			{
				if ( p.DefaultValue is not null )
					prop["default"] = JsonValue.Create( p.DefaultValue is Enum e ? e.ToString() : p.DefaultValue );
			}
			else
			{
				required.Add( p.Name );
			}

			properties[p.Name] = prop;
		}

		var schema = new JsonObject
		{
			["type"] = "object",
			["properties"] = properties
		};

		if ( required.Count > 0 )
			schema["required"] = required;

		return JsonSerializer.SerializeToElement( schema );
	}

	static JsonObject ForType( Type t )
	{
		t = Nullable.GetUnderlyingType( t ) ?? t;

		if ( t == typeof( string ) )
			return new JsonObject { ["type"] = "string" };

		if ( t == typeof( bool ) )
			return new JsonObject { ["type"] = "boolean" };

		if ( t == typeof( int ) || t == typeof( long ) || t == typeof( short ) || t == typeof( byte ) )
			return new JsonObject { ["type"] = "integer" };

		if ( t == typeof( float ) || t == typeof( double ) || t == typeof( decimal ) )
			return new JsonObject { ["type"] = "number" };

		if ( t.IsEnum )
		{
			var values = new JsonArray();
			foreach ( var name in Enum.GetNames( t ) )
				values.Add( name );

			return new JsonObject { ["type"] = "string", ["enum"] = values };
		}

		if ( t.IsArray )
			return new JsonObject { ["type"] = "array", ["items"] = ForType( t.GetElementType() ) };

		if ( t.IsGenericType && typeof( IEnumerable ).IsAssignableFrom( t ) )
			return new JsonObject { ["type"] = "array", ["items"] = ForType( t.GetGenericArguments()[0] ) };

		if ( t == typeof( JsonElement ) )
			return new JsonObject(); // accepts anything

		// fall back to a JSON-deserializable object
		return new JsonObject { ["type"] = "object" };
	}
}
notpointless.chomnr_mcp / Editor/Server/McpTypes.cs
Editor library
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;

namespace SboxMcp.Server;

/// <summary>
/// A tool as advertised to MCP clients via tools/list.
/// </summary>
public record McpToolDescriptor( string Name, string Description, JsonElement InputSchema );

/// <summary>
/// Result payload shapes defined by the MCP specification.
/// </summary>
public static class McpResults
{
	public const string ServerName = "sbox-mcp";
	public const string ServerVersion = "1.0.0";

	public static object Initialize( string negotiatedVersion ) => new
	{
		protocolVersion = negotiatedVersion,
		capabilities = new { tools = new { listChanged = false } },
		serverInfo = new { name = ServerName, version = ServerVersion }
	};

	public static object ToolsList( IEnumerable<McpToolDescriptor> tools ) => new
	{
		tools = tools.ToArray()
	};

	public static object TextContent( string text, bool isError = false ) => new
	{
		content = new object[] { new { type = "text", text } },
		isError
	};

	public static object ImageContent( string base64Png, string text = null )
	{
		var content = new List<object> { new { type = "image", data = base64Png, mimeType = "image/png" } };
		if ( !string.IsNullOrEmpty( text ) )
			content.Add( new { type = "text", text } );

		return new { content = content.ToArray(), isError = false };
	}
}

public static class McpVersion
{
	/// <summary>
	/// Protocol revisions this server understands. 2025-06-18 only: older
	/// revisions REQUIRE JSON-RPC batch support, which this server does not
	/// implement, so advertising them would be a lie.
	/// </summary>
	public static readonly string[] Supported = { "2025-06-18" };

	/// <summary>Exact match wins; anything else gets our newest revision.</summary>
	public static string Negotiate( string clientRequested ) =>
		Supported.Contains( clientRequested ) ? clientRequested : Supported[0];
}
notpointless.chomnr_mcp / Editor/Server/PathJail.cs
Editor library
using System;
using System.IO;

namespace SboxMcp.Server;

/// <summary>
/// Confines file access to the project root. Every file-touching tool resolves
/// paths through here.
/// </summary>
public static class PathJail
{
	/// <summary>
	/// Resolves <paramref name="path"/> (relative to root, or absolute) and
	/// throws if it escapes <paramref name="root"/>.
	/// </summary>
	public static string Resolve( string root, string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			throw new ArgumentException( "Path must not be empty" );

		var rootFull = Path.GetFullPath( root )
			.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar );

		var combined = Path.IsPathRooted( path ) ? path : Path.Combine( rootFull, path );
		var full = Path.GetFullPath( combined );

		if ( !full.Equals( rootFull, StringComparison.OrdinalIgnoreCase )
			&& !full.StartsWith( rootFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
		{
			throw new UnauthorizedAccessException( $"Path '{path}' is outside the project and cannot be accessed" );
		}

		return full;
	}
}
notpointless.chomnr_mcp / Editor/Tools/ExtraTools.cs
Editor library
using System;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

/// <summary>
/// High-value concrete tools on top of the universal mechanisms: tags,
/// bounds, orientation, bulk creation, component copy.
/// </summary>
public static class ExtraTools
{
	[McpTool( "gameobject_add_tag", "Adds a tag to a GameObject (tags drive collision filtering, queries and gameplay logic).", ToolCategory.GameObject, Writes = true )]
	public static object AddTag( [Desc( "GameObject id or unique name" )] string gameObject, string tag )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: add tag {tag}" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.Tags.Add( tag );

		return new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };
	}

	[McpTool( "gameobject_remove_tag", "Removes a tag from a GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object RemoveTag( [Desc( "GameObject id or unique name" )] string gameObject, string tag )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: remove tag {tag}" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.Tags.Remove( tag );

		return new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };
	}

	[McpTool( "gameobject_get_bounds", "Gets a GameObject's world-space bounding box (renderers + children).", ToolCategory.GameObject )]
	public static object GetBounds( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var go = FindGameObject( gameObject );
		var b = go.GetBounds();

		return new
		{
			gameObject = go.Name,
			center = V( b.Center ),
			size = V( b.Size ),
			mins = V( b.Mins ),
			maxs = V( b.Maxs )
		};
	}

	[McpTool( "gameobject_look_at", "Rotates a GameObject to face a target position or another GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object LookAt(
		[Desc( "GameObject id or unique name to rotate" )] string gameObject,
		[Desc( "Target world position [x, y, z]; ignored when targetObject is set" )] float[] position = null,
		[Desc( "Target GameObject id/name to face" )] string targetObject = null )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		var target = targetObject is not null
			? FindGameObject( targetObject ).WorldPosition
			: position is not null ? ToVector3( position, "position" )
			: throw new ArgumentException( "Pass either position or targetObject" );

		using var undo = session.UndoScope( "MCP: look at" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.WorldRotation = Rotation.LookAt( (target - go.WorldPosition).Normal );

		return new { gameObject = go.Name, rotation = A( go.WorldRotation ) };
	}

	[McpTool( "gameobject_create_many", "Creates several GameObjects at once (e.g. a grid or row). Returns their ids.", ToolCategory.GameObject, Writes = true )]
	public static object CreateMany(
		[Desc( "Base name; each gets a numeric suffix" )] string name,
		[Desc( "How many to create" )] int count,
		[Desc( "Position of the first [x, y, z]" )] float[] startPosition = null,
		[Desc( "Offset added per object [x, y, z]" )] float[] step = null,
		[Desc( "Parent id; omit for scene root" )] string parentId = null )
	{
		if ( count is < 1 or > 512 )
			throw new ArgumentException( "count must be 1..512" );

		var session = RequireSession();
		var parent = parentId is null ? null : FindGameObject( parentId );
		var start = startPosition is null ? Vector3.Zero : ToVector3( startPosition, "startPosition" );
		var delta = step is null ? new Vector3( 60, 0, 0 ) : ToVector3( step, "step" );

		using var undo = session.UndoScope( $"MCP: create {count} objects" ).WithGameObjectCreations().Push();

		var created = new object[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = session.Scene.CreateObject();
			go.Name = $"{name} {i + 1}";
			if ( parent is not null ) go.Parent = parent;
			go.WorldPosition = start + delta * i;
			created[i] = new { id = go.Id, name = go.Name };
		}

		return new { count, created };
	}

	[McpTool( "component_copy", "Copies all property values from one component to another GameObject's component of the same type (e.g. clone a configured renderer's settings). Creates the component on the target if it doesn't have one yet.", ToolCategory.Component, Writes = true )]
	public static object CopyComponent(
		[Desc( "Source GameObject id or unique name" )] string fromGameObject,
		[Desc( "Target GameObject id or unique name" )] string toGameObject,
		[Desc( "Component type name" )] string type )
	{
		var session = RequireSession();
		var source = FindComponent( FindGameObject( fromGameObject ), type );
		var toGo = FindGameObject( toGameObject );

		var existing = toGo.Components.GetAll<Component>( FindMode.EverythingInSelf )
			.FirstOrDefault( c => c.GetType() == source.GetType() );

		using var undo = session.UndoScope( $"MCP: copy {type}" )
			.WithComponentCreations()
			.WithComponentChanges( existing is not null ? new[] { existing } : Array.Empty<Component>() )
			.Push();

		// create a matching component on the target if it has none yet
		var target = existing ?? toGo.Components.Create( FindComponentType( type ) )
			?? throw new InvalidOperationException( $"Could not create a {type} on '{toGameObject}'" );

		if ( source.Serialize() is System.Text.Json.Nodes.JsonObject node )
		{
			// keep the target's own identity; copy only the values
			node.Remove( "__guid" );
			target.DeserializeImmediately( node );
		}

		return new { copied = type, from = fromGameObject, to = toGameObject, createdTarget = existing is null };
	}
}
notpointless.chomnr_mcp / Editor/Tools/GameObjectTools.cs
Editor library
using System;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

public static class GameObjectTools
{
	[McpTool( "gameobject_create", "Creates a new GameObject in the active scene.", ToolCategory.GameObject, Writes = true )]
	public static object Create(
		string name,
		[Desc( "Id of the parent GameObject; omit for scene root" )] string parentId = null,
		[Desc( "World position [x, y, z]" )] float[] position = null,
		[Desc( "Rotation [pitch, yaw, roll] in degrees" )] float[] rotation = null,
		[Desc( "Scale [x, y, z]" )] float[] scale = null )
	{
		var session = RequireSession();
		var parent = parentId is null ? null : FindGameObject( parentId );

		using var undo = session.UndoScope( $"MCP: create {name}" ).WithGameObjectCreations().Push();

		var go = session.Scene.CreateObject();
		go.Name = string.IsNullOrWhiteSpace( name ) ? "GameObject" : name;

		if ( parent is not null )
			go.Parent = parent;

		if ( position is not null )
			go.WorldPosition = ToVector3( position, "position" );

		if ( rotation is not null )
		{
			if ( rotation.Length != 3 )
				throw new ArgumentException( "'rotation' must be [pitch, yaw, roll]" );

			go.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );
		}

		if ( scale is not null )
			go.LocalScale = ToVector3( scale, "scale" );

		return Describe( go );
	}

	[McpTool( "gameobject_spawn_model", "Spawns a prop in one step: creates a GameObject, adds a ModelRenderer with the given model, and optionally a matching ModelCollider so physics/traces hit it. The common 'place a model' operation (vs gameobject_create + component_add + component_set_property). Note: withCollider uses the model's own collision mesh - dev primitives like box.vmdl have none, so add a BoxCollider yourself for those.", ToolCategory.GameObject, Writes = true )]
	public static object SpawnModel(
		[Desc( "Model asset path, e.g. 'models/dev/box.vmdl'" )] string model,
		[Desc( "Object name; defaults to the model's file name" )] string name = null,
		[Desc( "World position [x, y, z]" )] float[] position = null,
		[Desc( "Also add a ModelCollider (only solid if the model has a collision mesh)" )] bool withCollider = false )
	{
		if ( AssetSystem.FindByPath( model ) is null )
			throw new InvalidOperationException( $"No model at '{model}' - use asset_search with assetType 'model'" );

		var session = RequireSession();

		using var undo = session.UndoScope( "MCP: spawn model" ).WithGameObjectCreations().Push();

		var go = session.Scene.CreateObject();
		go.Name = string.IsNullOrWhiteSpace( name ) ? System.IO.Path.GetFileNameWithoutExtension( model ) : name;

		if ( position is not null )
			go.WorldPosition = ToVector3( position, "position" );

		var loaded = Model.Load( model );
		go.Components.Create<ModelRenderer>().Model = loaded;

		if ( withCollider )
			go.Components.Create<ModelCollider>().Model = loaded;

		return Describe( go );
	}

	[McpTool( "gameobject_spawn_light", "Spawns a light in one step: creates a GameObject with a PointLight, SpotLight, or DirectionalLight (optionally colored/aimed). Scenes need lighting - this is the one-call version.", ToolCategory.GameObject, Writes = true )]
	public static object SpawnLight(
		[Desc( "Light type: 'point', 'spot', or 'directional'" )] string lightType = "point",
		[Desc( "Object name; defaults to the light type" )] string name = null,
		[Desc( "World position [x, y, z]" )] float[] position = null,
		[Desc( "Rotation [pitch, yaw, roll] - aims spot/directional lights" )] float[] rotation = null,
		[Desc( "Light color [r, g, b] (0-1); omit for white" )] float[] color = null )
	{
		var session = RequireSession();

		using var undo = session.UndoScope( "MCP: spawn light" ).WithGameObjectCreations().Push();

		var go = session.Scene.CreateObject();

		if ( position is not null )
			go.WorldPosition = ToVector3( position, "position" );

		if ( rotation is not null )
		{
			if ( rotation.Length != 3 )
				throw new ArgumentException( "'rotation' must be [pitch, yaw, roll]" );

			go.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );
		}

		Light light = (lightType ?? "point").ToLowerInvariant() switch
		{
			"point" or "" => go.Components.Create<PointLight>(),
			"spot" => go.Components.Create<SpotLight>(),
			"directional" or "sun" or "dir" => go.Components.Create<DirectionalLight>(),
			_ => throw new ArgumentException( "lightType must be 'point', 'spot' or 'directional'" )
		};

		go.Name = string.IsNullOrWhiteSpace( name ) ? light.GetType().Name : name;

		if ( color is not null )
		{
			if ( color.Length is not (3 or 4) )
				throw new ArgumentException( "'color' must be [r, g, b] or [r, g, b, a]" );

			light.LightColor = new Color( color[0], color[1], color[2], color.Length > 3 ? color[3] : 1f );
		}

		return Describe( go );
	}

	[McpTool( "gameobject_spawn_camera", "Spawns a camera in one step: creates a GameObject with a CameraComponent, optionally positioned/aimed with a field of view. Every scene needs a camera to render in play mode.", ToolCategory.GameObject, Writes = true )]
	public static object SpawnCamera(
		[Desc( "Object name" )] string name = "Camera",
		[Desc( "World position [x, y, z]" )] float[] position = null,
		[Desc( "Rotation [pitch, yaw, roll] - where the camera looks" )] float[] rotation = null,
		[Desc( "Field of view in degrees (default 60)" )] float fieldOfView = 60f )
	{
		var session = RequireSession();

		using var undo = session.UndoScope( "MCP: spawn camera" ).WithGameObjectCreations().Push();

		var go = session.Scene.CreateObject();
		go.Name = string.IsNullOrWhiteSpace( name ) ? "Camera" : name;

		if ( position is not null )
			go.WorldPosition = ToVector3( position, "position" );

		if ( rotation is not null )
		{
			if ( rotation.Length != 3 )
				throw new ArgumentException( "'rotation' must be [pitch, yaw, roll]" );

			go.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );
		}

		go.Components.Create<CameraComponent>().FieldOfView = fieldOfView;

		return Describe( go );
	}

	[McpTool( "gameobject_delete", "Deletes a GameObject (and its children).", ToolCategory.GameObject, Writes = true )]
	public static object Delete( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );
		var name = go.Name;

		using var undo = session.UndoScope( $"MCP: delete {name}" )
			.WithGameObjectDestructions( new[] { go } ).Push();

		go.Destroy();
		return new { deleted = name };
	}

	[McpTool( "gameobject_rename", "Renames a GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object Rename( [Desc( "GameObject id or unique name" )] string gameObject, string newName )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: rename to {newName}" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();

		go.Name = newName;
		return Describe( go );
	}

	[McpTool( "gameobject_set_enabled", "Enables or disables a GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object SetEnabled( [Desc( "GameObject id or unique name" )] string gameObject, bool enabled )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: set enabled {enabled}" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();

		go.Enabled = enabled;
		return Describe( go );
	}

	[McpTool( "gameobject_set_parent", "Reparents a GameObject (keeps world position).", ToolCategory.GameObject, Writes = true )]
	public static object SetParent(
		[Desc( "GameObject id or unique name" )] string gameObject,
		[Desc( "New parent id; omit to move to scene root" )] string parentId = null )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );
		var parent = parentId is null ? (GameObject)session.Scene : FindGameObject( parentId );

		using var undo = session.UndoScope( "MCP: reparent" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();

		go.SetParent( parent, keepWorldPosition: true );
		return Describe( go );
	}

	[McpTool( "gameobject_get_transform", "Gets a GameObject's world and local transform.", ToolCategory.GameObject )]
	public static object GetTransform( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var go = FindGameObject( gameObject );

		return new
		{
			id = go.Id,
			name = go.Name,
			world = new { position = V( go.WorldPosition ), rotation = A( go.WorldRotation ), scale = V( go.WorldScale ) },
			local = new { position = V( go.LocalPosition ), rotation = A( go.LocalRotation ), scale = V( go.LocalScale ) }
		};
	}

	[McpTool( "gameobject_set_transform", "Sets position/rotation/scale on a GameObject. Omitted parts stay unchanged.", ToolCategory.GameObject, Writes = true )]
	public static object SetTransform(
		[Desc( "GameObject id or unique name" )] string gameObject,
		[Desc( "Position [x, y, z]" )] float[] position = null,
		[Desc( "Rotation [pitch, yaw, roll] in degrees" )] float[] rotation = null,
		[Desc( "Scale [x, y, z]" )] float[] scale = null,
		[Desc( "Apply in world space instead of local space" )] bool world = false )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( "MCP: set transform" )
			.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();

		if ( position is not null )
		{
			var v = ToVector3( position, "position" );
			if ( world ) go.WorldPosition = v; else go.LocalPosition = v;
		}

		if ( rotation is not null )
		{
			if ( rotation.Length != 3 )
				throw new ArgumentException( "'rotation' must be [pitch, yaw, roll]" );

			var r = Rotation.From( rotation[0], rotation[1], rotation[2] );
			if ( world ) go.WorldRotation = r; else go.LocalRotation = r;
		}

		if ( scale is not null )
			go.LocalScale = ToVector3( scale, "scale" );

		return GetTransform( go.Id.ToString() );
	}

	[McpTool( "gameobject_duplicate", "Duplicates a GameObject next to the original.", ToolCategory.GameObject, Writes = true )]
	public static object Duplicate( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: duplicate {go.Name}" ).WithGameObjectCreations().Push();

		var clone = go.Clone( go.WorldTransform, go.Parent, go.Enabled, $"{go.Name} (copy)" );
		return Describe( clone );
	}

	[McpTool( "gameobject_find", "Searches GameObjects by name substring, component type, and/or tag.", ToolCategory.GameObject )]
	public static object Find(
		[Desc( "Name substring (case-insensitive); omit to match all" )] string query = null,
		[Desc( "Only objects having this component type" )] string componentType = null,
		[Desc( "Only objects carrying this tag" )] string tag = null,
		int max = 50 )
	{
		var scene = RequireScene();

		var results = scene.GetAllObjects( false )
			.Where( o => o is not Scene )
			.Where( o => query is null || o.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) )
			.Where( o => tag is null || o.Tags.Has( tag ) )
			.Where( o => componentType is null || o.Components.GetAll<Component>( FindMode.EverythingInSelf )
				.Any( c => string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase )
					|| string.Equals( c.GetType().FullName, componentType, StringComparison.OrdinalIgnoreCase ) ) )
			.Take( max )
			.Select( Describe )
			.ToArray();

		return new { count = results.Length, results };
	}

	[McpTool( "gameobject_get_details", "Gets a GameObject with all component properties as JSON.", ToolCategory.GameObject )]
	public static object GetDetails( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var go = FindGameObject( gameObject );

		return new
		{
			id = go.Id,
			name = go.Name,
			enabled = go.Enabled,
			tags = go.Tags.TryGetAll().ToArray(),
			world = new { position = V( go.WorldPosition ), rotation = A( go.WorldRotation ), scale = V( go.WorldScale ) },
			parent = go.Parent is Scene ? null : (object)new { id = go.Parent?.Id, name = go.Parent?.Name },
			isPrefabInstance = go.IsPrefabInstance,
			prefabSource = go.PrefabInstanceSource,
			components = go.Components.GetAll<Component>( FindMode.EverythingInSelf )
				.Select( c => new
				{
					type = c.GetType().Name,
					enabled = c.Enabled,
					properties = c.Serialize()
				} ).ToArray()
		};
	}

	[McpTool( "gameobject_select", "Selects GameObjects in the editor (replaces current selection).", ToolCategory.GameObject )]
	public static object Select( [Desc( "GameObject ids or unique names" )] string[] gameObjects )
	{
		var session = RequireSession();
		var found = gameObjects.Select( FindGameObject ).ToList();

		session.Selection.Clear();
		foreach ( var go in found )
			session.Selection.Add( go );

		return new { selected = found.Select( g => g.Name ).ToArray() };
	}
}
notpointless.chomnr_mcp / Editor/UI/McpDock.cs
Editor library
using System;
using Editor;
using Sandbox;
using static Sandbox.Internal.GlobalToolsNamespace;
using SboxMcp.Integration;

namespace SboxMcp.UI;

/// <summary>
/// Top-level "MCP" menu in the editor menu bar (lands next to Help).
/// </summary>
public static class McpMenu
{
	[Menu( "Editor", "MCP/Open Dashboard", "hub" )]
	public static void OpenDashboard() => McpDock.Open();

	[Menu( "Editor", "MCP/Start Server", "play_arrow" )]
	public static void StartServer() => McpHost.Start();

	[Menu( "Editor", "MCP/Stop Server", "stop" )]
	public static void StopServer() => McpHost.Stop();
}

/// <summary>
/// The MCP dashboard: header with live status, tab bar, and the four pages.
/// Open it from the MCP menu in the menu bar.
/// </summary>
public class McpDock : Widget
{
	static McpDock _instance;

	/// <summary>The open dashboard instance, if any.</summary>
	public static McpDock Instance => _instance.IsValid() ? _instance : null;

	readonly HeaderBar _header;
	readonly TabButton[] _tabs;
	readonly Widget[] _pages;
	readonly OverviewPage _overview;
	readonly ActivityPage _activity;
	readonly ToolsPage _tools;

	int _active;
	readonly RealTimeSince _sinceCreated = 0;

	// Widget.MinimumWidth is a no-op for docks; Qt asks this instead
	protected override Vector2 MinimumSizeHint() => new( 360, 220 );

	protected override void OnResize()
	{
		base.OnResize();

		// remember the user's size for future sessions; the settle delay keeps
		// the initial open/layout resizes from clobbering the saved value
		if ( _sinceCreated > 1f && Width > 100 && Height > 100 )
			McpSettings.DockSize = Size;
	}

	/// <summary>Opens (or raises) the dashboard.</summary>
	public static McpDock Open()
	{
		var dock = Instance;

		if ( dock is null )
		{
			dock = new McpDock( EditorWindow );

			// restore the last size the user resized it to
			dock.Size = McpSettings.DockSize;

			// dock to the right by default (s&box removed DockArea.Floating and the
			// widget overload now takes a title/icon); the user can drag it out to
			// float or re-dock it anywhere
			EditorWindow.DockManager.AddDock( "MCP", "hub", dock, DockArea.Right );
			dock.Size = McpSettings.DockSize;
		}

		EditorWindow.DockManager.RaiseDock( dock );
		return dock;
	}

	public McpDock( Widget parent ) : base( parent )
	{
		_instance ??= this;

		Name = "McpDock";
		WindowTitle = "MCP";
		SetWindowIcon( "hub" );

		Layout = Layout.Column();

		_header = Layout.Add( new HeaderBar( this ) );

		var tabRow = Layout.AddRow();
		tabRow.Margin = new Sandbox.UI.Margin( 8, 4, 8, 0 );
		tabRow.Spacing = 2;

		_tabs = new[]
		{
			new TabButton( "Overview", "dashboard", this ),
			new TabButton( "Activity", "bolt", this ),
			new TabButton( "Tools", "construction", this ),
			new TabButton( "Settings", "tune", this )
		};

		for ( var i = 0; i < _tabs.Length; i++ )
		{
			var index = i;
			_tabs[i].Clicked = () => SetActive( index );
			tabRow.Add( _tabs[i] );
		}

		tabRow.AddStretchCell();

		var content = Layout.Add( new Widget( this ), 1 );
		content.Layout = Layout.Column();

		_overview = new OverviewPage( content );
		_activity = new ActivityPage( content );
		_tools = new ToolsPage( content );
		var settings = new SettingsPage( content );

		_pages = new Widget[] { _overview, _activity, _tools, settings };

		foreach ( var page in _pages )
			content.Layout.Add( page, 1 );

		SetActive( 0 );
		// no EditorEvent.Register(this) - QObject already registers every
		// widget; doing it again would run Tick twice per frame
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		if ( _instance == this )
			_instance = null;
	}

	void SetActive( int index )
	{
		_active = index;

		for ( var i = 0; i < _pages.Length; i++ )
		{
			_pages[i].Visible = i == index;
			_tabs[i].Active = i == index;
			_tabs[i].Update();
		}
	}

	[EditorEvent.Frame]
	public void Tick()
	{
		if ( !IsValid )
			return;

		var server = McpHost.Server;
		var running = server?.IsRunning ?? false;
		var sessions = server?.Sessions.Count ?? 0;

		_header.StatusColor = running ? Palette.Running : (McpHost.LastError is null ? Palette.Stopped : Palette.Error);
		_header.StatusText = !running
			? (McpHost.LastError is null ? "stopped" : "error")
			: sessions > 0 ? $"running · {sessions} client{(sessions == 1 ? "" : "s")}" : "running";
		_header.Pulse = running ? (MathF.Sin( RealTime.Now * 3f ) + 1f) * 0.5f : 0f;
		_header.Update();

		// badge pending approvals on the Activity tab
		var pending = PermissionGate.Pending.Count;
		if ( _tabs[1].Badge != pending )
		{
			_tabs[1].Badge = pending;
			_tabs[1].Update();
		}

		_overview.Tick();
		_activity.Tick();
		_tools.Tick();
	}
}
notpointless.chomnr_mcp / Editor/UI/McpStatusPill.cs
Editor library
using System;
using Editor;
using Sandbox;
using SboxMcp.Integration;

namespace SboxMcp.UI;

/// <summary>
/// Tiny MCP indicator in the editor's status bar: a status dot, the label and
/// the connected-client count. Click to open the dashboard.
/// </summary>
public class McpStatusPill : Widget
{
	string _signature;

	public McpStatusPill() : base( null )
	{
		FixedWidth = 70;
		FixedHeight = 20;
		Cursor = CursorShape.Finger;
		ToolTip = "s&box MCP - click to open the dashboard";
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();

		var server = McpHost.Server;
		var running = server?.IsRunning ?? false;
		var sessions = server?.Sessions.Count ?? 0;
		var color = running ? Palette.Running : (McpHost.LastError is null ? Palette.Stopped : Palette.Error);

		if ( Paint.HasMouseOver )
		{
			Paint.SetBrush( Color.White.WithAlpha( 0.06f ) );
			Paint.DrawRect( LocalRect, 4 );
		}

		Paint.SetBrush( color );
		Paint.DrawCircle( new Vector2( LocalRect.Left + 9, LocalRect.Center.y ), 7 );

		Paint.SetPen( Palette.TextDim );
		Paint.SetDefaultFont( 7, 600 );
		Paint.DrawText( new Rect( LocalRect.Left + 17, LocalRect.Top, LocalRect.Width - 19, LocalRect.Height ),
			running && sessions > 0 ? $"MCP · {sessions}" : "MCP", TextFlag.LeftCenter );
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );
		McpDock.Open();
	}

	// widgets are auto-registered for editor events; repaint when state changes
	[EditorEvent.Frame]
	public void Tick()
	{
		if ( !IsValid )
			return;

		var server = McpHost.Server;
		var sig = $"{server?.IsRunning}|{server?.Sessions.Count}|{McpHost.LastError is not null}";

		if ( sig == _signature )
			return;

		_signature = sig;
		Update();
	}
}
notpointless.chomnr_mcp / Editor/Integration/McpSettings.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using static Sandbox.Internal.GlobalToolsNamespace;

namespace SboxMcp.Integration;

/// <summary>A user-imported tool: a public static method from another library.
/// Signature (comma-joined parameter type names) distinguishes overloads;
/// null when loaded from older persisted data.</summary>
public sealed record ImportedToolDef( string Assembly, string Type, string Method, string Signature = null );

/// <summary>
/// Persisted settings. EditorCookie is not thread-safe and must only be
/// touched on the editor main thread, so values are cached in fields:
/// getters are safe from any thread, setters are UI (main thread) only.
/// </summary>
public static class McpSettings
{
	public const int DefaultPort = 9090;

	static int _port = DefaultPort;
	static bool _portFromEnv;
	static bool _autoStart = true;
	static PermissionMode _mode = PermissionMode.FullAccess;

	/// <summary>True when the port came from the SBOX_MCP_PORT env var - used to
	/// isolate a second editor instance on its own port without persisting to (and
	/// disturbing) the shared EditorCookie every instance reads.</summary>
	public static bool IsPortFromEnv => _portFromEnv;

	/// <summary>Called once from the editor main thread before anything reads settings.</summary>
	internal static void LoadFromCookies()
	{
		// env override wins so you can launch an isolated instance:
		// SBOX_MCP_PORT=9191 sbox-dev.exe ... -> binds 9191, cookie untouched
		var env = Environment.GetEnvironmentVariable( "SBOX_MCP_PORT" );
		if ( int.TryParse( env, out var envPort ) && envPort is > 0 and < 65536 )
		{
			_port = envPort;
			_portFromEnv = true;
		}
		else
		{
			_port = EditorCookie.Get( "SboxMcp.Port", DefaultPort );
		}

		_autoStart = EditorCookie.Get( "SboxMcp.AutoStart", true );
		_mode = EditorCookie.Get( "SboxMcp.PermissionMode", PermissionMode.FullAccess );
		LoadExtras();
	}

	public static int Port
	{
		get => _port;
		// don't clobber the shared cookie when an env override is driving the port
		set { _port = value; if ( !_portFromEnv ) EditorCookie.Set( "SboxMcp.Port", value ); }
	}

	public static bool AutoStart
	{
		get => _autoStart;
		set { _autoStart = value; EditorCookie.Set( "SboxMcp.AutoStart", value ); }
	}

	public static PermissionMode Mode
	{
		get => _mode;
		set { _mode = value; EditorCookie.Set( "SboxMcp.PermissionMode", value ); }
	}

	// ---- dashboard window size (persisted) ---------------------------------

	static Vector2 _dockSize = new( 420, 560 );

	public static Vector2 DockSize
	{
		get => _dockSize;
		set
		{
			_dockSize = value;
			EditorCookie.Set( "SboxMcp.DockSize", $"{(int)value.x}x{(int)value.y}" );
		}
	}

	// ---- per-tool enable/disable overrides (persisted) ---------------------

	// reference-swapped on change so worker threads can read without locks;
	// absence of a key means "use the tool's default"
	static Dictionary<string, bool> _toolDisabledOverrides = new();

	/// <summary>The user's explicit choice for a tool, or null = tool default.</summary>
	public static bool? GetToolDisabledOverride( string toolName ) =>
		_toolDisabledOverrides.TryGetValue( toolName, out var disabled ) ? disabled : null;

	/// <summary>UI/main thread only (writes a cookie).</summary>
	public static void SetToolDisabled( string toolName, bool disabled )
	{
		var next = new Dictionary<string, bool>( _toolDisabledOverrides ) { [toolName] = disabled };
		_toolDisabledOverrides = next;
		EditorCookie.Set( "SboxMcp.ToolOverrides",
			string.Join( ";", next.Select( kv => $"{kv.Key}={(kv.Value ? 1 : 0)}" ) ) );
	}

	// ---- imported tools (persisted) ----------------------------------------

	static List<ImportedToolDef> _importedTools = new();

	public static IReadOnlyList<ImportedToolDef> ImportedTools => _importedTools;

	/// <summary>UI/main thread only (writes a cookie).</summary>
	public static void AddImportedTool( ImportedToolDef def )
	{
		if ( _importedTools.Contains( def ) )
			return;

		_importedTools = new List<ImportedToolDef>( _importedTools ) { def };
		SaveImports();
	}

	/// <summary>UI/main thread only (writes a cookie).</summary>
	public static void RemoveImportedTool( ImportedToolDef def )
	{
		_importedTools = _importedTools.Where( d => d != def ).ToList();
		SaveImports();
	}

	static void SaveImports() =>
		EditorCookie.Set( "SboxMcp.ImportedTools", JsonSerializer.Serialize( _importedTools ) );

	static void LoadExtras()
	{
		var size = EditorCookie.Get( "SboxMcp.DockSize", "" );
		var sizeParts = size.Split( 'x' );
		if ( sizeParts.Length == 2 && int.TryParse( sizeParts[0], out var w ) && int.TryParse( sizeParts[1], out var h ) )
			_dockSize = new Vector2( Math.Max( w, 360 ), Math.Max( h, 220 ) );

		var overrides = EditorCookie.Get( "SboxMcp.ToolOverrides", "" );
		_toolDisabledOverrides = overrides
			.Split( ';', StringSplitOptions.RemoveEmptyEntries )
			.Select( pair => pair.Split( '=' ) )
			.Where( parts => parts.Length == 2 )
			.ToDictionary( parts => parts[0], parts => parts[1] == "1" );

		var imports = EditorCookie.Get( "SboxMcp.ImportedTools", "" );
		try
		{
			_importedTools = string.IsNullOrWhiteSpace( imports )
				? new List<ImportedToolDef>()
				: JsonSerializer.Deserialize<List<ImportedToolDef>>( imports ) ?? new List<ImportedToolDef>();
		}
		catch ( JsonException )
		{
			_importedTools = new List<ImportedToolDef>();
		}
	}
}
notpointless.chomnr_mcp / Editor/Integration/ToolImporter.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using SboxMcp.Registry;

namespace SboxMcp.Integration;

/// <summary>
/// Lets the user expose public static methods from other installed libraries
/// as MCP tools. Imports are persisted (per editor, via cookies) and re-bound
/// every session; methods whose library is gone simply don't register until
/// it returns.
/// </summary>
public static class ToolImporter
{
	static readonly Type[] BindableParams =
	{
		typeof( string ), typeof( int ), typeof( long ), typeof( float ), typeof( double ),
		typeof( bool ), typeof( string[] ), typeof( int[] ), typeof( float[] )
	};

	// system/engine assemblies are never offered as import sources
	static readonly string[] ExcludedPrefixes =
	{
		"System", "Microsoft", "netstandard", "mscorlib", "Sandbox", "Facepunch",
		"NLog", "Sentry", "Refit", "protobuf", "Mono", "MonoMod", "Skia", "Topten",
		"Humanizer", "Azure", "LiteDB", "Fleck", "Zio", "ExCSS", "xunit", "JetBrains"
	};

	/// <summary>
	/// True for assemblies compiled from installed s&box libraries (named
	/// "package.{org}.{ident}[.editor]"), excluding the open project's own code.
	/// </summary>
	public static bool IsLibraryAssembly( Assembly assembly )
	{
		var name = assembly.GetName().Name ?? "";
		if ( !name.StartsWith( "package.", StringComparison.OrdinalIgnoreCase ) )
			return false;

		var config = Sandbox.Project.Current?.Config;
		if ( config is null )
			return true;

		return !name.StartsWith( $"package.{config.Org}.{config.Ident}", StringComparison.OrdinalIgnoreCase );
	}

	public static string FriendlyName( Assembly assembly )
	{
		var name = assembly.GetName().Name ?? "?";
		return name.StartsWith( "package.", StringComparison.OrdinalIgnoreCase ) ? name[8..] : name;
	}

	/// <summary>Loaded assemblies that look like user libraries with importable methods.</summary>
	public static IEnumerable<Assembly> CandidateAssemblies()
	{
		var own = typeof( ToolImporter ).Assembly;

		return AppDomain.CurrentDomain.GetAssemblies()
			.Where( a => !a.IsDynamic && a != own )
			.Where( a =>
			{
				var name = a.GetName().Name ?? "";
				return name.Length > 0 && !ExcludedPrefixes.Any( p => name.StartsWith( p, StringComparison.OrdinalIgnoreCase ) );
			} )
			.Where( a => CandidateMethods( a ).Any() )
			.OrderBy( a => a.GetName().Name );
	}

	/// <summary>Public static methods with simple, schema-expressible parameters.</summary>
	public static IEnumerable<MethodInfo> CandidateMethods( Assembly assembly )
	{
		Type[] types;
		try { types = assembly.GetExportedTypes(); }
		catch { yield break; }

		foreach ( var type in types.Where( t => t.IsClass && !t.IsGenericTypeDefinition ) )
		{
			foreach ( var method in type.GetMethods( BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly ) )
			{
				if ( method.IsSpecialName || method.IsGenericMethodDefinition )
					continue;

				if ( method.GetParameters().All( p => BindableParams.Contains( p.ParameterType ) || p.ParameterType.IsEnum ) )
					yield return method;
			}
		}
	}

	public static string ToolNameFor( ImportedToolDef def )
	{
		var typeName = def.Type.Split( '.' ).Last();
		// include a short signature suffix so overloads and same-named types
		// don't collide on one tool name
		var suffix = string.IsNullOrEmpty( def.Signature ) ? "" : "_" + Math.Abs( def.Signature.GetHashCode() % 10000 );
		return Sanitize( $"lib_{typeName}_{def.Method}{suffix}" );
	}

	static string Sanitize( string name ) =>
		new( name.Select( c => char.IsLetterOrDigit( c ) ? char.ToLowerInvariant( c ) : '_' ).ToArray() );

	static string SignatureOf( MethodInfo method ) =>
		string.Join( ",", method.GetParameters().Select( p => p.ParameterType.Name ) );

	public static bool IsImported( MethodInfo method ) =>
		McpSettings.ImportedTools.Contains( DefFor( method ) );

	public static ImportedToolDef DefFor( MethodInfo method ) =>
		new( method.DeclaringType?.Assembly.GetName().Name, method.DeclaringType?.FullName, method.Name, SignatureOf( method ) );

	/// <summary>Imports a method now and persists the choice.</summary>
	public static void Import( MethodInfo method )
	{
		var def = DefFor( method );
		McpSettings.AddImportedTool( def );
		Register( McpHost.Registry, def, method );
	}

	/// <summary>Removes an import now and persists the choice.</summary>
	public static void Unimport( MethodInfo method )
	{
		var def = DefFor( method );
		McpSettings.RemoveImportedTool( def );
		McpHost.Registry?.Remove( ToolNameFor( def ) );
	}

	/// <summary>Re-binds every persisted import that still resolves.</summary>
	public static void RegisterSaved( ToolRegistry registry )
	{
		foreach ( var def in McpSettings.ImportedTools )
		{
			var method = Resolve( def );
			if ( method is not null )
				Register( registry, def, method );
			else
				McpHost.Log.Warning( $"Imported tool {def.Type}.{def.Method} not found ({def.Assembly} missing?) - it will return when the library does" );
		}
	}

	static MethodInfo Resolve( ImportedToolDef def )
	{
		var assembly = AppDomain.CurrentDomain.GetAssemblies()
			.LastOrDefault( a => a.GetName().Name == def.Assembly );

		var type = assembly?.GetType( def.Type );
		var overloads = type?.GetMethods( BindingFlags.Public | BindingFlags.Static )
			.Where( m => m.Name == def.Method && !m.IsGenericMethodDefinition )
			.ToArray() ?? Array.Empty<MethodInfo>();

		// match the exact overload the user picked; older data (null signature)
		// falls back to the first, preserving prior behavior
		return def.Signature is null
			? overloads.FirstOrDefault()
			: overloads.FirstOrDefault( m => SignatureOf( m ) == def.Signature ) ?? overloads.FirstOrDefault();
	}

	static void Register( ToolRegistry registry, ImportedToolDef def, MethodInfo method )
	{
		if ( registry is null )
			return;

		var parameters = string.Join( ", ", method.GetParameters().Select( p => p.Name ) );
		var registered = registry.AddImported(
			ToolNameFor( def ),
			$"Imported from the '{def.Assembly}' library: {def.Type.Split( '.' ).Last()}.{def.Method}({parameters})",
			ToolCategory.Imported,
			method );

		if ( registered is null )
			McpHost.Log.Warning( $"Could not import {def.Type}.{def.Method} - a tool named '{ToolNameFor( def )}' already exists" );
	}
}
notpointless.chomnr_mcp / Editor/Tools/CodeTools.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
using SboxMcp.Integration;
using SboxMcp.Registry;
using static SboxMcp.Tools.AssetTools;

namespace SboxMcp.Tools;

public static class CodeTools
{
	static readonly string[] SkippedDirs = { "\\obj\\", "\\bin\\", "/obj/", "/bin/" };
	static readonly string[] SourceExtensions = { ".cs", ".razor", ".scss", ".shader", ".hlsl" };

	/// <summary>Skip build output and any dot-directory (.git, .sbox, .removed-libraries...).</summary>
	static bool IsSkipped( string fullPath )
	{
		if ( SkippedDirs.Any( s => fullPath.Contains( s, StringComparison.OrdinalIgnoreCase ) ) )
			return true;

		// any path segment starting with '.'
		return fullPath.Replace( '\\', '/' ).Split( '/' ).Any( seg => seg.StartsWith( '.' ) && seg.Length > 1 );
	}

	[McpTool( "code_list_files", "Lists source files in the project: C# (.cs), UI (.razor/.scss) and shaders. Saving a file hot-reloads automatically.", ToolCategory.Code )]
	public static object ListFiles(
		[Desc( "Subdirectory filter relative to project root, e.g. 'Code/Player'" )] string subdir = null,
		[Desc( "Include files from installed Libraries" )] bool includeLibraries = false )
	{
		var root = ProjectRoot;
		var searchRoot = subdir is null ? root : ResolveInProject( subdir );

		if ( !Directory.Exists( searchRoot ) )
			throw new InvalidOperationException( $"No directory '{subdir}' in the project" );

		var files = Directory.EnumerateFiles( searchRoot, "*.*", SearchOption.AllDirectories )
			.Where( f => SourceExtensions.Contains( Path.GetExtension( f ), StringComparer.OrdinalIgnoreCase ) )
			.Where( f => !IsSkipped( f ) )
			.Where( f => includeLibraries || !f.Contains( Path.DirectorySeparatorChar + "Libraries" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
			.Select( f => Path.GetRelativePath( root, f ).Replace( '\\', '/' ) )
			.OrderBy( f => f )
			.ToArray();

		return new { count = files.Length, files };
	}

	[McpTool( "code_search", "Searches project source files (C#/Razor/SCSS/shaders) for a substring or regex - find where a symbol is used, a class is defined, etc. Returns file:line matches.", ToolCategory.Code )]
	public static object Search(
		[Desc( "Text or regex to find" )] string pattern,
		[Desc( "Treat pattern as a regular expression" )] bool regex = false,
		[Desc( "Case-sensitive match" )] bool caseSensitive = false,
		[Desc( "Limit to a subdirectory relative to project root" )] string subdir = null,
		[Desc( "Also search installed library source under Libraries/" )] bool includeLibraries = false,
		int max = 100 )
	{
		if ( string.IsNullOrEmpty( pattern ) )
			throw new ArgumentException( "pattern must not be empty" );

		var root = ProjectRoot;
		var searchRoot = subdir is null ? root : ResolveInProject( subdir );
		if ( !Directory.Exists( searchRoot ) )
			throw new InvalidOperationException( $"No directory '{subdir}' - use code_list_files to see the layout" );

		var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
		System.Text.RegularExpressions.Regex rx = null;
		if ( regex )
			rx = new System.Text.RegularExpressions.Regex( pattern,
				caseSensitive ? System.Text.RegularExpressions.RegexOptions.None : System.Text.RegularExpressions.RegexOptions.IgnoreCase );

		var matches = new List<object>();

		foreach ( var file in Directory.EnumerateFiles( searchRoot, "*.*", SearchOption.AllDirectories ) )
		{
			if ( !SourceExtensions.Contains( Path.GetExtension( file ), StringComparer.OrdinalIgnoreCase ) )
				continue;
			if ( IsSkipped( file ) )
				continue;
			if ( !includeLibraries && file.Contains( Path.DirectorySeparatorChar + "Libraries" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
				continue;

			var rel = Path.GetRelativePath( root, file ).Replace( '\\', '/' );
			var lines = File.ReadAllLines( file );
			for ( var i = 0; i < lines.Length; i++ )
			{
				var hit = rx is not null ? rx.IsMatch( lines[i] ) : lines[i].Contains( pattern, comparison );
				if ( !hit ) continue;

				matches.Add( new { file = rel, line = i + 1, text = lines[i].Trim() } );
				if ( matches.Count >= max ) break;
			}
			if ( matches.Count >= max ) break;
		}

		return new { count = matches.Count, truncated = matches.Count >= max, matches };
	}

	[McpTool( "code_read_file", "Reads a project source file.", ToolCategory.Code )]
	public static object ReadFile( [Desc( "Path relative to project root, e.g. 'Code/Player.cs'" )] string path )
	{
		var absolute = ResolveInProject( path );

		if ( !File.Exists( absolute ) )
			throw new InvalidOperationException( $"No file at '{path}' - use code_list_files" );

		return new { path, content = File.ReadAllText( absolute ) };
	}

	[McpTool( "code_write_file", "Writes a project source file (creating it if missing). The editor hot-reloads changed code automatically; check editor_get_logs / code_get_compile_errors afterwards.", ToolCategory.Code, Writes = true )]
	public static object WriteFile(
		[Desc( "Path relative to project root, e.g. 'Code/Player.cs'" )] string path,
		[Desc( "Full new file content" )] string content )
	{
		var absolute = ResolveInProject( path );

		Directory.CreateDirectory( Path.GetDirectoryName( absolute ) );
		File.WriteAllText( absolute, content );

		return new { written = path, note = "hot-reload triggers automatically; verify with code_get_compile_errors" };
	}

	[McpTool( "code_edit_file", "Replaces an exact text snippet in a project source file - a targeted edit, versus code_write_file which rewrites the whole file. The old text must appear EXACTLY ONCE (include surrounding context to make it unique). The editor hot-reloads afterward.", ToolCategory.Code, Writes = true )]
	public static object EditFile(
		[Desc( "Path relative to project root, e.g. 'Code/Player.cs'" )] string path,
		[Desc( "Exact existing text to replace (must be unique in the file, whitespace included)" )] string oldText,
		[Desc( "Replacement text" )] string newText )
	{
		if ( string.IsNullOrEmpty( oldText ) )
			throw new ArgumentException( "oldText must not be empty - use code_write_file to create/overwrite a file" );

		var absolute = ResolveInProject( path );
		if ( !File.Exists( absolute ) )
			throw new InvalidOperationException( $"No file at '{path}' - use code_list_files" );

		var content = File.ReadAllText( absolute );

		var first = content.IndexOf( oldText, StringComparison.Ordinal );
		if ( first < 0 )
			throw new InvalidOperationException( $"The old text was not found in '{path}' - read it with code_read_file and match exactly (whitespace included)" );
		if ( content.IndexOf( oldText, first + 1, StringComparison.Ordinal ) >= 0 )
			throw new InvalidOperationException( $"The old text appears more than once in '{path}' - include more surrounding context to make it unique" );

		File.WriteAllText( absolute, content.Remove( first, oldText.Length ).Insert( first, newText ) );

		return new { edited = path, note = "hot-reload triggers automatically; verify with code_get_compile_errors" };
	}

	[McpTool( "code_create_component", "Scaffolds a new Component C# file (a script you can add to GameObjects) with the standard boilerplate and any [Property] fields. The editor hot-reloads it, then add it with component_add.", ToolCategory.Code, Writes = true )]
	public static object CreateComponent(
		[Desc( "Component class name, e.g. 'PlayerMovement'" )] string className,
		[Desc( "Namespace; omit for the project default" )] string @namespace = null,
		[Desc( "Property fields as 'Type Name' pairs, e.g. ['float Speed', 'GameObject Target']" )] string[] properties = null,
		[Desc( "Add an OnUpdate() method body" )] bool withUpdate = true )
	{
		if ( string.IsNullOrWhiteSpace( className ) || !char.IsLetter( className[0] ) )
			throw new ArgumentException( "className must start with a letter" );

		var ns = @namespace ?? DefaultNamespace();
		var sb = new System.Text.StringBuilder();
		sb.AppendLine( "using Sandbox;" ).AppendLine();
		sb.AppendLine( $"namespace {ns};" ).AppendLine();
		sb.AppendLine( $"public sealed class {className} : Component" );
		sb.AppendLine( "{" );

		foreach ( var p in properties ?? Array.Empty<string>() )
		{
			var parts = p.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
			if ( parts.Length == 2 )
				sb.AppendLine( $"\t[Property] public {parts[0]} {parts[1]} {{ get; set; }}" ).AppendLine();
		}

		if ( withUpdate )
		{
			sb.AppendLine( "\tprotected override void OnUpdate()" );
			sb.AppendLine( "\t{" );
			sb.AppendLine( "\t\t// runs every frame while the component is enabled" );
			sb.AppendLine( "\t}" );
		}

		sb.AppendLine( "}" );

		var path = $"Code/{className}.cs";
		var absolute = ResolveInProject( path );
		if ( File.Exists( absolute ) )
			throw new InvalidOperationException( $"'{path}' already exists - edit it with code_write_file" );

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

		return new { created = path, className, note = $"hot-reloading; then component_add(go, \"{className}\")" };
	}

	static string DefaultNamespace()
	{
		// RootNamespace lives in the .sbproj; read it from there rather than
		// guessing the config property name
		try
		{
			var sbproj = Directory.GetFiles( ProjectRoot, "*.sbproj" ).FirstOrDefault();
			if ( sbproj is not null
				&& System.Text.Json.Nodes.JsonNode.Parse( File.ReadAllText( sbproj ) ) is System.Text.Json.Nodes.JsonObject json )
			{
				// RootNamespace lives at Metadata.Compiler.RootNamespace; fall back to root
				var ns = json["Metadata"]?["Compiler"]?["RootNamespace"]?.GetValue<string>()
					?? json["RootNamespace"]?.GetValue<string>();
				if ( !string.IsNullOrWhiteSpace( ns ) )
					return ns;
			}
		}
		catch { /* fall through to default */ }

		return "Sandbox";
	}

	[McpTool( "code_run_static_method", "Invokes a public static method from project code, optionally WITH arguments - write a method with code_write_file/code_edit_file, wait for hot-reload, then call it to test or inspect game state. If the method returns a Task/Task<T> it is AWAITED and its result returned (not the Task object). Returns the result's ToString.", ToolCategory.Code, Writes = true )]
	public static async Task<object> RunStaticMethod(
		[Desc( "Type name, e.g. 'MyGame.DebugHelpers'" )] string typeName,
		[Desc( "Public static method name" )] string methodName,
		[Desc( "Positional argument values as a JSON array, e.g. [5, \"hi\", true]; omit for a no-arg method" )] JsonElement args = default )
	{
		var typeDesc = Sandbox.Internal.GlobalToolsNamespace.EditorTypeLibrary.GetType( typeName )
			?? throw new InvalidOperationException( $"No type '{typeName}' - is it compiled? Check code_get_compile_errors" );

		var clrType = typeDesc.TargetType
			?? throw new InvalidOperationException( $"'{typeName}' has no usable CLR type" );

		// tolerate args passed as a real array OR a stringified array (MCP clients
		// often stringify) - was the cause of spurious "taking 0 arguments" errors
		var argList = ToolHelpers.NormalizeArgs( args );
		var argCount = argList.Length;

		var method = clrType.GetMethods( BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy )
			.FirstOrDefault( m => m.Name == methodName && !m.IsGenericMethodDefinition && m.GetParameters().Length == argCount )
			?? throw new InvalidOperationException(
				$"'{typeName}' has no public static method '{methodName}' taking {argCount} argument(s) - use api_get_type to see its methods" );

		// marshal each JSON arg to the parameter's type (BindOptions resolves
		// engine value types like Vector3/Rotation) - a hard error if it can't
		var parameters = method.GetParameters();
		var bound = new object[parameters.Length];
		for ( var i = 0; i < parameters.Length; i++ )
		{
			try
			{
				bound[i] = argList[i].Deserialize( parameters[i].ParameterType, ToolRegistry.BindOptions );
			}
			catch ( Exception e )
			{
				throw new InvalidOperationException(
					$"Argument {i} ('{parameters[i].Name}') could not be read as {parameters[i].ParameterType.Name}: {e.Message}" );
			}
		}

		object result;
		try
		{
			result = method.Invoke( null, bound );
		}
		catch ( TargetInvocationException e ) when ( e.InnerException is not null )
		{
			throw e.InnerException;
		}

		// await a Task/Task<T> so a diagnostic method can be async without the
		// caller getting back "System.Threading.Tasks.Task`1[System.String]"
		var awaited = await ToolHelpers.AwaitIfTask( result );

		return new { invoked = $"{typeName}.{methodName}", args = argCount, result = awaited?.ToString() ?? "null" };
	}

	[McpTool( "build_info", "Reports the identity of the currently-loaded build: a server buildId plus, for a given type, the MVID/timestamp of the assembly that type lives in. Call it after a compile to confirm your NEW code is actually live (the MVID changes on every recompile) - replaces planting a throwaway Log.Info canary to check for stale assemblies.", ToolCategory.Code )]
	public static object BuildInfo(
		[Desc( "Optional type to inspect, e.g. 'MyGame.DebugHelpers' - reports the assembly that holds it" )] string typeName = null )
	{
		string Mvid( Assembly a ) => a.ManifestModule.ModuleVersionId.ToString( "N" ).Substring( 0, 12 );

		string LastWrite( Assembly a )
		{
			try
			{
				return string.IsNullOrEmpty( a.Location ) || !File.Exists( a.Location )
					? null
					: File.GetLastWriteTime( a.Location ).ToString( "yyyy-MM-dd HH:mm:ss" );
			}
			catch { return null; }
		}

		var server = Assembly.GetExecutingAssembly();
		object typeBuild = null;

		if ( !string.IsNullOrWhiteSpace( typeName ) )
		{
			var desc = Sandbox.Internal.GlobalToolsNamespace.EditorTypeLibrary.GetType( typeName );
			var clr = desc?.TargetType
				?? throw new InvalidOperationException( $"No type '{typeName}' - is it compiled? Check code_get_compile_errors" );

			var asm = clr.Assembly;
			typeBuild = new
			{
				type = clr.FullName,
				assembly = asm.GetName().Name,
				buildId = Mvid( asm ),
				location = string.IsNullOrEmpty( asm.Location ) ? "(in-memory / hot-loaded)" : asm.Location,
				assemblyLastWrite = LastWrite( asm )
			};
		}

		return new
		{
			serverBuildId = Mvid( server ),
			serverAssembly = server.GetName().Name,
			serverLastWrite = LastWrite( server ),
			type = typeBuild,
			note = "buildId (assembly MVID) changes on every recompile. Store it, recompile, call again: same buildId = the running process is still on the OLD build (stale); different = the new code is live."
		};
	}

	[McpTool( "code_delete_file", "Deletes a project source file (e.g. remove a component you no longer need). Jailed to the project; not undoable.", ToolCategory.Code, Writes = true )]
	public static object DeleteFile( [Desc( "Path relative to project root, e.g. 'Code/OldThing.cs'" )] string path )
	{
		var absolute = ResolveInProject( path );
		if ( !File.Exists( absolute ) )
			throw new InvalidOperationException( $"No file at '{path}' - use code_list_files" );

		File.Delete( absolute );
		return new { deleted = path, note = "the editor will hot-reload; check code_get_compile_errors for references you may need to remove" };
	}

	[McpTool( "compile_await", "Waits for code compilation to SETTLE after an edit, then reports compile errors and whether the running session hot-swapped the new code. Call this right after code_write_file/code_edit_file instead of code_get_compile_errors - it fixes the log-race (compile_errors can read clean before compilation finishes) and makes an invisible hot-swap visible.", ToolCategory.Code )]
	public static async Task<object> CompileAwait(
		[Desc( "Max seconds to wait for compilation to go quiet" )] int timeoutSeconds = 20 )
	{
		var startHotload = SessionTracker.LastHotloadAt;
		var deadline = DateTime.Now.AddSeconds( Math.Clamp( timeoutSeconds, 1, 120 ) );

		var lastSeq = LogCapture.LatestSeq;
		var lastActivity = DateTime.Now;
		var hotSwapped = false;
		var settled = false;

		// wait until the console log stream goes quiet (compilation finished
		// emitting diagnostics); note a hotload if the loaded assembly changed
		while ( DateTime.Now < deadline )
		{
			await Task.Delay( 200 );

			if ( SessionTracker.LastHotloadAt is DateTime h && h != startHotload )
				hotSwapped = true;

			var seq = LogCapture.LatestSeq;
			if ( seq != lastSeq )
			{
				lastSeq = seq;
				lastActivity = DateTime.Now;
			}
			else if ( (DateTime.Now - lastActivity).TotalMilliseconds >= 1200 )
			{
				settled = true;
				break;
			}
		}

		// only C# COMPILE errors (error CSxxxx) - not engine resource-load errors
		// which also contain the word "error"
		var errors = LogCapture.Recent( 300 )
			.Where( l => l.Message is not null && l.Message.Contains( "error CS", StringComparison.OrdinalIgnoreCase ) )
			.Select( l => l.Message )
			.Distinct()
			.Take( 25 )
			.ToArray();

		return (object)new
		{
			settled,
			hotSwapped,
			clean = errors.Length == 0,
			errorCount = errors.Length,
			errors,
			// MVID of the running server assembly - changes on every recompile, so a
			// caller can tell "is the code I'm calling actually the build I just made?"
			// apart without planting a throwaway Log.Info canary. Compare across calls.
			buildId = Assembly.GetExecutingAssembly().ManifestModule.ModuleVersionId.ToString( "N" ).Substring( 0, 12 ),
			note = !settled
				? "Timed out before compilation went quiet - poll again or raise timeoutSeconds."
				: hotSwapped
					? "Compilation settled and a hotload swapped the new code into the running process."
					: "Compilation settled; no hotload observed (code may already be current, or an interface-shape change forced a full reload)."
		};
	}

	[McpTool( "code_get_compile_errors", "Gets recent compiler errors and warnings from the editor console.", ToolCategory.Code )]
	public static object GetCompileErrors( int max = 50 )
	{
		var entries = LogCapture.Recent( max, "warning", diagnosticsOnly: true )
			.Select( l => new { time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message } )
			.ToArray();

		// fall back to error-looking log lines if no tagged diagnostics are buffered
		if ( entries.Length == 0 )
		{
			entries = LogCapture.Recent( max, "error" )
				.Where( l => l.Message is not null )
				.Select( l => new { time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message } )
				.ToArray();
		}

		return new
		{
			count = entries.Length,
			note = "Entries come from the editor console log stream. An empty list right after code_write_file may mean compilation has not finished - wait a moment and call again.",
			entries
		};
	}
}
notpointless.chomnr_mcp / Editor/Tools/EditorTools.cs
Editor library
using System;
using System.Linq;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using SboxMcp.Integration;
using SboxMcp.Registry;
using SboxMcp.Server;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

public static class EditorTools
{
	[McpTool( "editor_get_logs", "Reads recent editor console output (newest first) - compile diagnostics, editor warnings/errors. NOTE: game-side Log.* emitted while play mode is running may not all appear here; to inspect play-mode state, read component values with component_get_property / get_component_property (they reflect the live play scene).", ToolCategory.Editor )]
	public static object GetLogs(
		int count = 100,
		[Desc( "Minimum severity: trace, info, warning or error" )] string minSeverity = null,
		[Desc( "Only entries newer than this cursor (pass back the 'cursor' from the previous call to poll incrementally instead of re-reading old lines)" )] long sinceSeq = 0 )
	{
		var logs = LogCapture.Recent( count, minSeverity, sinceSeq: sinceSeq )
			.Select( l => new { seq = l.Seq, time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message } )
			.ToArray();

		// cursor = newest sequence number; pass it as sinceSeq next call for a
		// clean "only what's new" tail
		return new { count = logs.Length, cursor = LogCapture.LatestSeq, logs };
	}

	[McpTool( "logs_search", "Searches the captured console log by regex, minimum severity, and time window - returns matches WITH their stack traces (invaluable for errors/exceptions). Cleaner than paging editor_get_logs when hunting a specific message.", ToolCategory.Editor )]
	public static object LogsSearch(
		[Desc( "Regex to match in the message; omit to match everything" )] string pattern = null,
		[Desc( "Minimum severity: trace, info, warning or error" )] string minSeverity = null,
		[Desc( "Only entries from the last N seconds; omit for the whole buffer" )] int withinSeconds = 0,
		int max = 50 )
	{
		var since = withinSeconds > 0 ? System.DateTime.Now.AddSeconds( -withinSeconds ) : (System.DateTime?)null;

		var results = LogCapture.Search( pattern, minSeverity, max, since )
			.Select( l => new { seq = l.Seq, time = l.Time.ToString( "HH:mm:ss" ), level = l.Level, logger = l.Logger, message = l.Message, stack = l.Stack } )
			.ToArray();

		return new { count = results.Length, cursor = LogCapture.LatestSeq, results };
	}

	[McpTool( "editor_clear_logs", "Clears the captured console log buffer.", ToolCategory.Editor )]
	public static object ClearLogs()
	{
		LogCapture.Clear();
		return new { cleared = true };
	}

	[McpTool( "editor_screenshot", "Captures what the game camera sees, as an image. DURING PLAY this is the player's live point of view (renders Game.ActiveScene through its active CameraComponent) - use it to see what the player sees. In edit mode it renders the edit scene's camera. For an arbitrary angle instead, use editor_screenshot_from. Needs an enabled CameraComponent.", ToolCategory.Editor )]
	public static object Screenshot(
		[Desc( "Image width in pixels" )] int width = 1280,
		[Desc( "Image height in pixels" )] int height = 720 )
	{
		var session = RequireSession();
		var scene = session.IsPlaying && Game.ActiveScene is not null ? Game.ActiveScene : session.Scene;

		if ( scene.Camera is null )
			throw new InvalidOperationException(
				"The scene has no enabled CameraComponent to render from - add one with component_add" );

		width = Math.Clamp( width, 64, 4096 );
		height = Math.Clamp( height, 64, 4096 );

		var pixmap = new Pixmap( width, height );

		if ( !scene.RenderToPixmap( pixmap ) )
			throw new InvalidOperationException( "Rendering failed - check editor_get_logs; ensure a valid camera, or try editor_screenshot_from" );

		var png = pixmap.GetPng();
		return new RawMcpResult( McpResults.ImageContent(
			Convert.ToBase64String( png ),
			$"{(session.IsPlaying ? "game" : "scene")} camera view, {width}x{height}" ) );
	}

	[McpTool( "editor_screenshot_from", "Renders the scene from an arbitrary viewpoint (no camera component needed) - use it to inspect what you built from any angle.", ToolCategory.Editor )]
	public static object ScreenshotFrom(
		[Desc( "Camera world position [x, y, z]" )] float[] position,
		[Desc( "Camera rotation [pitch, yaw, roll]; ignored when lookAt is set" )] float[] rotation = null,
		[Desc( "GameObject id/name to aim the camera at" )] string lookAt = null,
		int width = 1280,
		int height = 720 )
	{
		var session = RequireSession();
		var scene = session.Scene;

		width = Math.Clamp( width, 64, 4096 );
		height = Math.Clamp( height, 64, 4096 );

		// temporary camera, intentionally outside any undo scope
		var go = scene.CreateObject();
		try
		{
			go.Name = "__mcp_temp_camera";
			go.WorldPosition = ToVector3( position, "position" );

			if ( lookAt is not null )
			{
				var target = FindGameObject( lookAt );
				go.WorldRotation = Rotation.LookAt( target.WorldPosition - go.WorldPosition );
			}
			else if ( rotation is not null )
			{
				if ( rotation.Length != 3 )
					throw new ArgumentException( "'rotation' must be [pitch, yaw, roll]" );

				go.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );
			}

			var camera = go.Components.Create<CameraComponent>();
			var pixmap = new Pixmap( width, height );

			if ( !camera.RenderToPixmap( pixmap ) )
				throw new InvalidOperationException( "Rendering failed" );

			return new RawMcpResult( McpResults.ImageContent(
				Convert.ToBase64String( pixmap.GetPng() ),
				$"view from [{string.Join( ", ", position )}], {width}x{height}" ) );
		}
		finally
		{
			go.Destroy();
		}
	}

	[McpTool( "editor_frame_object", "Points the editor viewport camera at a GameObject so the user can see it.", ToolCategory.Editor )]
	public static object FrameObject( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		session.FrameTo( go.GetBounds() );
		return new { framed = go.Name };
	}

	[McpTool( "editor_play", "Enters play mode with the current scene.", ToolCategory.Editor, Writes = true )]
	public static object Play()
	{
		var session = RequireSession();

		if ( session.IsPlaying )
			return new { playing = true, note = "already in play mode" };

		EditorScene.Play();
		return new { playing = SceneEditorSession.Active?.IsPlaying ?? false };
	}

	[McpTool( "editor_stop", "Exits play mode.", ToolCategory.Editor, Writes = true )]
	public static object Stop()
	{
		var session = RequireSession();

		if ( !session.IsPlaying )
			return new { playing = false, note = "was not in play mode" };

		EditorScene.Stop();
		return new { playing = false };
	}

	[McpTool( "editor_is_playing", "Whether the editor is currently in play mode.", ToolCategory.Editor )]
	public static object IsPlaying()
	{
		return new { playing = SceneEditorSession.Active?.IsPlaying ?? false };
	}

	[McpTool( "session_info", "Play-session identity and timing - use it to tell restarts apart (play clones reuse the editor's GUIDs, so 'did the scene restart?' is otherwise a guess): whether play mode is running, when the current play session started, a play-session counter, when code last hot-reloaded, and when the MCP server started.", ToolCategory.Editor )]
	public static object SessionInfo()
	{
		string Stamp( System.DateTime? t ) => t?.ToString( "yyyy-MM-dd HH:mm:ss" );

		return new
		{
			playing = SboxMcp.Integration.SessionTracker.IsPlaying,
			playSessionCount = SboxMcp.Integration.SessionTracker.PlaySessionCount,
			playStartedAt = Stamp( SboxMcp.Integration.SessionTracker.PlayStartedAt ),
			lastHotloadAt = Stamp( SboxMcp.Integration.SessionTracker.LastHotloadAt ),
			serverStartedAt = Stamp( SboxMcp.Integration.SessionTracker.ServerStartedAt )
		};
	}

	[McpTool( "perf_get_stats", "Measures the frame rate over a short window (by sampling the editor frame counter) and reports FPS + average frame time - use it to quantitatively confirm a perf fix (e.g. removing debug-draw overdraw) instead of eyeballing sphere counts. During play this reflects the running game's tick loop.", ToolCategory.Editor )]
	public static async Task<object> PerfGetStats(
		[Desc( "Measurement window in seconds (0.2-10)" )] double seconds = 1.0 )
	{
		seconds = Math.Clamp( seconds, 0.2, 10 );

		var startFrames = SessionTracker.FrameCount;
		var startTime = DateTime.Now;
		await Task.Delay( (int)(seconds * 1000) );
		var elapsed = (DateTime.Now - startTime).TotalSeconds;
		var frames = SessionTracker.FrameCount - startFrames;
		var fps = elapsed > 0 ? frames / elapsed : 0;

		return (object)new
		{
			fps = Math.Round( fps, 1 ),
			frameTimeMs = fps > 0 ? (object)Math.Round( 1000.0 / fps, 2 ) : null,
			frames,
			windowSeconds = Math.Round( elapsed, 2 ),
			playing = SessionTracker.IsPlaying,
			note = "FPS is the editor frame loop (which is the game tick loop during play). GPU draw-call counters aren't exposed by the editor API. Measure before and after a change to compare."
		};
	}

	[McpTool( "editor_run_console_command", "Runs an editor console command (e.g. 'clear', convars).", ToolCategory.Editor, Writes = true )]
	public static object RunConsoleCommand( [Desc( "The console command line to run" )] string command )
	{
		Editor.ConsoleSystem.Run( command );
		return new { ran = command, note = "check editor_get_logs for output" };
	}

	[McpTool( "convar_get", "Reads a console variable's value (game/engine settings).", ToolCategory.Editor )]
	public static object ConVarGet( [Desc( "ConVar name, e.g. 'sv_gravity'" )] string name )
	{
		var value = Sandbox.ConsoleSystem.GetValue( name, null );
		if ( value is null )
			throw new InvalidOperationException( $"No console variable '{name}' - check the exact name with editor_run_console_command 'find {name}'" );

		return new { name, value };
	}

	[McpTool( "convar_set", "Sets a console variable's value.", ToolCategory.Editor, Writes = true )]
	public static object ConVarSet(
		[Desc( "ConVar name" )] string name,
		[Desc( "New value (string)" )] string value )
	{
		Sandbox.ConsoleSystem.SetValue( name, value );
		return new { name, value = Sandbox.ConsoleSystem.GetValue( name, value ) };
	}

	[McpTool( "editor_get_project_info", "Gets the current project: title, ident, type, paths.", ToolCategory.Editor )]
	public static object GetProjectInfo()
	{
		var project = Project.Current
			?? throw new InvalidOperationException( "No project is loaded" );

		return new
		{
			title = project.Config?.Title,
			ident = project.Config?.Ident,
			org = project.Config?.Org,
			type = project.Config?.Type,
			rootPath = project.GetRootPath(),
			hasCode = project.HasCodePath(),
			hasEditorCode = project.HasEditorPath()
		};
	}

	[McpTool( "editor_get_selection", "Gets the GameObjects currently selected in the editor.", ToolCategory.Editor )]
	public static object GetSelection()
	{
		var session = RequireSession();
		var selected = session.Selection.OfType<GameObject>()
			.Select( o => new { id = o.Id, name = o.Name } )
			.ToArray();

		return new { count = selected.Length, selected };
	}
}
notpointless.chomnr_mcp / Editor/Tools/ProjectTools.cs
Editor library
using System;
using System.IO;
using System.Linq;
using System.Text.Json.Nodes;
using Editor;
using Sandbox;
using SboxMcp.Registry;

namespace SboxMcp.Tools;

/// <summary>
/// Project configuration: input actions and startup scene.
/// </summary>
public static class ProjectTools
{
	[McpTool( "input_list_actions", "Lists the project's input actions (the names used with Input.Pressed/Down in code).", ToolCategory.Editor )]
	public static object ListActions()
	{
		var settings = ProjectSettings.Input
			?? throw new InvalidOperationException( "Input settings are unavailable - is a project loaded?" );

		var actions = (settings.Actions ?? new())
			.Select( a => new { name = a.Name, group = a.GroupName, keyboard = a.KeyboardCode, gamepad = a.GamepadCode.ToString() } )
			.ToArray();

		return new { count = actions.Length, actions };
	}

	[McpTool( "input_add_action", "Adds an input action to the project (use the name with Input.Pressed in code). Applies on next play.", ToolCategory.Editor, Writes = true )]
	public static object AddAction(
		[Desc( "Action name, e.g. 'Dash'" )] string name,
		[Desc( "Keyboard key, e.g. 'shift', 'e', 'mouse1'" )] string keyboardCode,
		[Desc( "Group shown in settings UI, e.g. 'Movement'" )] string group = "Other" )
	{
		var settings = ProjectSettings.Input
			?? throw new InvalidOperationException( "Input settings are unavailable - is a project loaded?" );

		settings.Actions ??= new();

		if ( !System.Text.RegularExpressions.Regex.IsMatch( name ?? "", @"^[a-zA-Z0-9_\-]+$" ) )
			throw new ArgumentException( "Action name may only contain letters, digits, underscore and hyphen (no spaces)" );

		if ( settings.Actions.Any( a => string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) ) )
			throw new InvalidOperationException( $"An input action named '{name}' already exists - input_list_actions shows it; remove it first with input_remove_action" );

		settings.Actions.Add( new InputAction { Name = name, KeyboardCode = keyboardCode, GroupName = group } );
		SaveInputSettings( settings );

		return new
		{
			added = name,
			keyboard = keyboardCode,
			group,
			note = "IMPORTANT: input action bindings register on the NEXT play session, not the current one. If you're already in play mode, editor_stop then editor_play (or restart) before the binding works - otherwise Input.Pressed(\"" + name + "\") silently returns false."
		};
	}

	[McpTool( "input_remove_action", "Removes an input action from the project.", ToolCategory.Editor, Writes = true )]
	public static object RemoveAction( [Desc( "Action name" )] string name )
	{
		var settings = ProjectSettings.Input
			?? throw new InvalidOperationException( "Input settings are unavailable - is a project loaded?" );

		var action = settings.Actions?.FirstOrDefault( a => string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) )
			?? throw new InvalidOperationException( $"No input action named '{name}' - use input_list_actions" );

		settings.Actions.Remove( action );
		SaveInputSettings( settings );

		return new { removed = action.Name };
	}

	static void SaveInputSettings( InputSettings settings )
	{
		var root = AssetTools.ProjectRoot;
		var dir = Path.Combine( root, "ProjectSettings" );
		Directory.CreateDirectory( dir );
		// use the config's own Serialize so the __schema/__version header is
		// written the way the engine expects (keeps upgraders working)
		File.WriteAllText( Path.Combine( dir, "Input.config" ),
			settings.Serialize().ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );
	}

	[McpTool( "project_set_startup_scene", "Sets the scene the game opens with when launched.", ToolCategory.Editor, Writes = true )]
	public static object SetStartupScene( [Desc( "Scene asset path, e.g. 'scenes/main_menu.scene'" )] string scenePath )
	{
		var asset = AssetSystem.FindByPath( scenePath )
			?? throw new InvalidOperationException( $"No scene at '{scenePath}' - use scene_list" );

		var root = AssetTools.ProjectRoot;
		var sbproj = Directory.GetFiles( root, "*.sbproj" ).FirstOrDefault()
			?? throw new InvalidOperationException( "No .sbproj file found in the project root" );

		var json = JsonNode.Parse( File.ReadAllText( sbproj ) ) as JsonObject
			?? throw new InvalidOperationException( "Could not parse the .sbproj file" );

		var metadata = json["Metadata"] as JsonObject;
		if ( metadata is null )
			json["Metadata"] = metadata = new JsonObject();

		metadata["StartupScene"] = asset.Path;
		File.WriteAllText( sbproj, json.ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );

		return new { startupScene = asset.Path };
	}
}
notpointless.chomnr_mcp / Editor/Tools/SceneTools.cs
Editor library
using System;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

public static class SceneTools
{
	[McpTool( "scene_get_status", "Gets the active scene: name, play state, unsaved changes, object count.", ToolCategory.Scene )]
	public static object GetStatus()
	{
		var session = RequireSession();
		var scene = session.Scene;

		return new
		{
			name = scene.Name,
			isPlaying = session.IsPlaying,
			sceneTarget = ToolHelpers.SceneTargetMode ?? "active",
			hasUnsavedChanges = session.HasUnsavedChanges,
			objectCount = scene.GetAllObjects( false ).Count( o => o is not Sandbox.Scene ),
			selection = session.Selection.OfType<Sandbox.GameObject>().Select( o => new { id = o.Id, name = o.Name } ).ToArray()
		};
	}

	[McpTool( "scene_setup_basic", "Bootstraps a usable scene in the current scene: a ground plane (with a collider), a directional light, and a camera - so you can start building and playing immediately.", ToolCategory.Scene, Writes = true )]
	public static object SetupBasic(
		[Desc( "Ground size multiplier (scales a dev box)" )] float groundScale = 10f )
	{
		var session = RequireSession();
		var box = Model.Load( "models/dev/box.vmdl" );

		using var undo = session.UndoScope( "MCP: setup basic scene" ).WithGameObjectCreations().Push();

		var ground = session.Scene.CreateObject();
		ground.Name = "Ground";
		ground.LocalScale = new Vector3( groundScale, groundScale, 1f );
		ground.Components.Create<ModelRenderer>().Model = box;
		// A BoxCollider (primitive), NOT a ModelCollider: the dev box model has
		// no collision mesh, so a ModelCollider would leave the ground non-solid
		// and objects would fall straight through it.
		ground.Components.Create<BoxCollider>();

		var sun = session.Scene.CreateObject();
		sun.Name = "Sun";
		sun.WorldRotation = Rotation.From( 60, 45, 0 );
		sun.Components.Create<DirectionalLight>();

		var cam = session.Scene.CreateObject();
		cam.Name = "Camera";
		cam.WorldPosition = new Vector3( -350, 0, 200 );
		cam.WorldRotation = Rotation.From( 25, 0, 0 );
		cam.Components.Create<CameraComponent>().FieldOfView = 70f;

		return new { created = new[] { "Ground", "Sun", "Camera" }, note = "ground has a collider; a directional light and camera are set - ready to build and play" };
	}

	[McpTool( "scene_diff", "Compares the in-memory editor scene to its saved .scene file on disk: reports unsaved changes and which top-level GameObjects were added or removed since the last save. Review it before scene_save to catch an accidental overwrite (e.g. saving over the wrong scene) and to make deliberate saves reviewable.", ToolCategory.Scene )]
	public static object SceneDiff()
	{
		var session = RequireSession();
		var scene = session.Scene;

		var memObjects = scene.Children.Where( o => o is not Sandbox.Scene ).Select( o => o.Name ).ToArray();

		string scenePath = null;
		string[] diskObjects = null;
		string diskNote = null;

		try
		{
			scenePath = scene.Source?.ResourcePath;
			var file = string.IsNullOrEmpty( scenePath ) ? null : AssetSystem.FindByPath( scenePath )?.GetSourceFile( true );

			if ( !string.IsNullOrEmpty( file ) && File.Exists( file ) )
			{
				using var doc = System.Text.Json.JsonDocument.Parse( File.ReadAllText( file ) );
				if ( doc.RootElement.TryGetProperty( "GameObjects", out var arr ) && arr.ValueKind == System.Text.Json.JsonValueKind.Array )
				{
					diskObjects = arr.EnumerateArray()
						.Select( e => e.TryGetProperty( "Name", out var n ) ? n.GetString() : null )
						.Where( n => n is not null )
						.ToArray();
				}
			}
			else
			{
				diskNote = "scene has not been saved to disk yet (or its source file was not found)";
			}
		}
		catch ( Exception e )
		{
			diskNote = "could not read/parse the disk scene: " + e.Message;
		}

		var added = diskObjects is null ? null : memObjects.Except( diskObjects ).ToArray();
		var removed = diskObjects is null ? null : diskObjects.Except( memObjects ).ToArray();

		return new
		{
			scene = scene.Name,
			scenePath,
			hasUnsavedChanges = session.HasUnsavedChanges,
			inMemoryObjects = memObjects.Length,
			onDiskObjects = diskObjects?.Length,
			addedSinceSave = added,
			removedSinceSave = removed,
			note = diskNote ?? (session.HasUnsavedChanges
				? "In-memory scene differs from disk - scene_save to persist (or you may lose these changes on restart)."
				: "In-memory scene matches the last save.")
		};
	}

	[McpTool( "scene_target", "Chooses which scene the object/component tools act on while PLAY mode is running: 'editor' = the persistent edit scene (plant a toggle/route/prop that survives Stop and restarts - the fix for losing objects to restarts), 'play' = the live throwaway play clone, 'active' (default) = whatever is focused. Set 'editor' before planting persistent objects during play, then reset to 'active'. No effect when not playing.", ToolCategory.Scene, Writes = true )]
	public static object SetSceneTarget( [Desc( "'editor', 'play', or 'active'" )] string target = "active" )
	{
		var t = (target ?? "active").ToLowerInvariant();
		if ( t is not ("editor" or "play" or "active") )
			throw new ArgumentException( "target must be 'editor', 'play', or 'active'" );

		ToolHelpers.SceneTargetMode = t == "active" ? null : t;

		var resolved = RequireSession();
		return new
		{
			target = t,
			resolvedScene = resolved.Scene?.Name,
			resolvedIsPlaying = resolved.IsPlaying,
			note = "Applies to subsequent object/component tools until changed. Reset to 'active' when done."
		};
	}

	[McpTool( "scene_load_map", "Imports a map into the scene by creating a GameObject with a MapInstance component - loads Hammer/Source2 .vmap geometry as a level. Set mapName to a map asset path like 'maps/mylevel.vmap' (find them with asset_search assetType 'vmap').", ToolCategory.Scene, Writes = true )]
	public static object LoadMap(
		[Desc( "Map asset name/path, e.g. 'maps/mylevel.vmap'" )] string mapName,
		[Desc( "Name for the map GameObject" )] string objectName = "Map",
		[Desc( "World origin [x, y, z] for the map" )] float[] position = null )
	{
		if ( string.IsNullOrWhiteSpace( mapName ) )
			throw new ArgumentException( "mapName is required (e.g. 'maps/mylevel.vmap')" );

		var session = RequireSession();

		using var undo = session.UndoScope( "MCP: load map" ).WithGameObjectCreations().Push();

		var go = session.Scene.CreateObject();
		go.Name = string.IsNullOrWhiteSpace( objectName ) ? "Map" : objectName;

		if ( position is not null )
			go.WorldPosition = ToVector3( position, "position" );

		var map = go.Components.Create<MapInstance>();
		map.MapName = mapName;

		return new { loaded = mapName, gameObject = go.Name, id = go.Id, isLoaded = map.IsLoaded };
	}

	[McpTool( "scene_add_asset", "Adds any asset to the scene, dispatching by type: a model (.vmdl) -> GameObject with a ModelRenderer; a prefab (.prefab) -> instantiated; a map (.vmap) -> GameObject with a MapInstance. The one-call 'put this asset in the scene'. For materials/textures/sounds (which aren't scene objects), apply them to a component instead.", ToolCategory.Asset, Writes = true )]
	public static object AddAsset(
		[Desc( "Asset path, e.g. 'models/x.vmdl', 'prefabs/y.prefab', 'maps/z.vmap'" )] string path,
		[Desc( "Object name; defaults to the asset's file name" )] string name = null,
		[Desc( "World position [x, y, z]" )] float[] position = null )
	{
		if ( AssetSystem.FindByPath( path ) is null )
			throw new InvalidOperationException( $"No asset at '{path}' - use asset_search to find it" );

		var session = RequireSession();
		var pos = position is null ? Vector3.Zero : ToVector3( position, "position" );
		var displayName = string.IsNullOrWhiteSpace( name ) ? Path.GetFileNameWithoutExtension( path ) : name;
		var ext = Path.GetExtension( path ).ToLowerInvariant();

		using var undo = session.UndoScope( "MCP: add asset" ).WithGameObjectCreations().Push();

		switch ( ext )
		{
			case ".vmdl":
			{
				var go = session.Scene.CreateObject();
				go.Name = displayName;
				go.WorldPosition = pos;
				go.Components.Create<ModelRenderer>().Model = Model.Load( path );
				return new { added = "model", gameObject = go.Name, id = go.Id };
			}
			case ".prefab":
			{
				var prefabFile = ResourceLibrary.Get<PrefabFile>( path )
					?? throw new InvalidOperationException( $"Prefab '{path}' could not be loaded" );
				var prefabScene = SceneUtility.GetPrefabScene( prefabFile )
					?? throw new InvalidOperationException( $"Prefab '{path}' could not be loaded" );
				var instance = prefabScene.Clone( new Transform( pos ) );
				return new { added = "prefab", gameObject = instance.Name, id = instance.Id };
			}
			case ".vmap":
			{
				var go = session.Scene.CreateObject();
				go.Name = displayName;
				go.WorldPosition = pos;
				go.Components.Create<MapInstance>().MapName = path;
				return new { added = "map", gameObject = go.Name, id = go.Id };
			}
			default:
				throw new InvalidOperationException(
					$"Don't know how to add a '{ext}' asset as a scene object. Supported: .vmdl (model), .prefab, .vmap (map). Materials/textures/sounds are applied to components (material_create, component_set_property, sound_play), not added as objects." );
		}
	}

	[McpTool( "navmesh_generate", "Enables and bakes the scene's NavMesh from its static/ground colliders so NPCs and enemies can pathfind. Set the agent size to match your characters. Run after the level geometry exists.", ToolCategory.Scene, Writes = true )]
	public static object NavMeshGenerate(
		[Desc( "Agent radius (character half-width)" )] float agentRadius = 16f,
		[Desc( "Agent height" )] float agentHeight = 72f,
		[Desc( "Max step height the agent can climb" )] float agentStepSize = 18f )
	{
		var scene = RequireScene();
		var nav = scene.NavMesh
			?? throw new InvalidOperationException( "This scene has no NavMesh object" );

		nav.IsEnabled = true;
		nav.AgentRadius = agentRadius;
		nav.AgentHeight = agentHeight;
		nav.AgentStepSize = agentStepSize;
		nav.Generate( scene.PhysicsWorld );

		return new
		{
			enabled = true,
			agentRadius,
			agentHeight,
			isGenerating = nav.IsGenerating,
			note = "generation may finish asynchronously; query paths with navmesh_find_path"
		};
	}

	[McpTool( "navmesh_find_path", "Finds a navigation path between two world points on the scene's NavMesh (for NPC/enemy movement) - returns the waypoints. Requires navmesh_generate first.", ToolCategory.Scene )]
	public static object NavMeshFindPath(
		[Desc( "Start point [x, y, z]" )] float[] from,
		[Desc( "Destination point [x, y, z]" )] float[] to )
	{
		var scene = RequireScene();
		var nav = scene.NavMesh;
		if ( nav is null || !nav.IsEnabled )
			throw new InvalidOperationException( "The scene's NavMesh is not enabled - call navmesh_generate first" );

		var target = ToVector3( to, "to" );
		var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest
		{
			Start = ToVector3( from, "from" ),
			Target = target
		} );

		var points = path.Points is null ? Array.Empty<float[]>() : path.Points.Select( p => V( p.Position ) ).ToArray();
		var reaches = path.Status == Sandbox.Navigation.NavMeshPathStatus.Complete;

		// a Partial path's LAST waypoint is the closest reachable point, which the
		// engine leaves short of the target - callers must gate on `reaches`, never
		// on distance-to-last-point, or they'll treat unreachable targets as reached
		var lastPos = points.Length > 0 ? path.Points.Last().Position : (Vector3?)null;
		var endsAt = lastPos.HasValue ? V( lastPos.Value ) : null;
		var gap = lastPos.HasValue ? Vector3.DistanceBetween( lastPos.Value, target ) : (float?)null;

		return new
		{
			reaches,                 // TRUE only when the target is actually reachable
			found = reaches,         // kept for back-compat
			status = path.Status.ToString(),
			waypoints = points.Length,
			endsAt,                  // real endpoint of the path (may be short of the target)
			requestedEnd = V( target ),
			endpointGap = gap.HasValue ? (object)Math.Round( gap.Value, 2 ) : null,
			points,
			note = reaches
				? null
				: "PARTIAL/failed path: the target is NOT reachable. 'endsAt' is the closest reachable point (endpointGap units short) - do not treat it as the destination. Gate movement/AI logic on 'reaches'."
		};
	}

	static Sandbox.Navigation.NavMesh RequireNav()
	{
		var nav = RequireScene().NavMesh;
		if ( nav is null || !nav.IsEnabled )
			throw new InvalidOperationException( "The scene's NavMesh is not enabled - call navmesh_generate first" );

		return nav;
	}

	[McpTool( "navmesh_random_point", "Returns a random reachable point on the scene's NavMesh - for AI wander targets. Optionally sampled near a position within a radius. Requires navmesh_generate first.", ToolCategory.Scene )]
	public static object NavMeshRandomPoint(
		[Desc( "Center to sample near [x, y, z]; omit for anywhere on the navmesh" )] float[] near = null,
		[Desc( "Sample radius around 'near'" )] float radius = 500f )
	{
		var nav = RequireNav();
		var point = near is not null ? nav.GetRandomPoint( ToVector3( near, "near" ), radius ) : nav.GetRandomPoint();

		return point is null
			? new { found = false, point = (float[])null }
			: new { found = true, point = V( point.Value ) };
	}

	[McpTool( "navmesh_closest_point", "Snaps a world point to the nearest point on the scene's NavMesh within a radius (clamp a spawn/target onto walkable ground). Requires navmesh_generate first.", ToolCategory.Scene )]
	public static object NavMeshClosestPoint(
		[Desc( "World point [x, y, z]" )] float[] position,
		[Desc( "Search radius" )] float radius = 200f )
	{
		var nav = RequireNav();
		var point = nav.GetClosestPoint( ToVector3( position, "position" ), radius );

		return point is null
			? new { found = false, point = (float[])null }
			: new { found = true, point = V( point.Value ) };
	}

	[McpTool( "scene_get_hierarchy", "Gets the scene's GameObject tree with ids, names and component types.", ToolCategory.Scene )]
	public static object GetHierarchy(
		[Desc( "How many levels deep to expand" )] int maxDepth = 4,
		[Desc( "Id of a GameObject to use as the root; omit for the whole scene" )] string rootId = null )
	{
		if ( rootId is not null )
			return DescribeTree( FindGameObject( rootId ), maxDepth );

		var scene = RequireScene();
		return new
		{
			scene = scene.Name,
			objects = scene.Children.Select( c => DescribeTree( c, maxDepth - 1 ) ).ToArray()
		};
	}

	[McpTool( "scene_create", "Creates a new scene (with a camera and a light) and makes it active. Save it with scene_save_as.", ToolCategory.Scene, Writes = true )]
	public static object Create()
	{
		var session = SceneEditorSession.CreateDefault();
		session.MakeActive();
		return new { created = session.Scene.Name, note = "unsaved - use scene_save_as to write it to disk" };
	}

	[McpTool( "scene_open", "Opens a scene (or prefab) from disk in the editor and makes it active.", ToolCategory.Scene )]
	public static object Open( [Desc( "Scene asset path, e.g. 'scenes/minimal.scene'" )] string scenePath )
	{
		var session = SceneEditorSession.CreateFromPath( scenePath )
			?? throw new InvalidOperationException( $"No scene at '{scenePath}' - use scene_list" );

		session.MakeActive();
		return new { opened = session.Scene.Name };
	}

	[McpTool( "scene_list", "Lists all scene assets in the project.", ToolCategory.Scene )]
	public static object List()
	{
		var scenes = AssetSystem.All
			.Where( a => string.Equals( a.AssetType?.FileExtension, "scene", StringComparison.OrdinalIgnoreCase ) )
			.Select( a => a.Path )
			.OrderBy( p => p )
			.ToArray();

		return new { count = scenes.Length, scenes };
	}

	[McpTool( "scene_save", "Saves the active scene to disk. Fails for never-saved scenes - use scene_save_as for those.", ToolCategory.Scene, Writes = true )]
	public static object Save()
	{
		var session = RequireSession();

		if ( session.IsPlaying )
			throw new InvalidOperationException( "Cannot save while playing - editor_stop first (play-mode changes are discarded by design)" );

		if ( session.Scene.Source is null )
			throw new InvalidOperationException( "This scene has never been saved - use scene_save_as with a path" );

		session.Save( false );
		return new { saved = true, scene = session.Scene.Name };
	}

	[McpTool( "scene_save_as", "Saves the active scene to a new path under Assets/ (works for never-saved scenes).", ToolCategory.Scene, Writes = true )]
	public static object SaveAs( [Desc( "Assets-relative path ending in .scene, e.g. 'scenes/level1.scene'" )] string scenePath )
	{
		var session = RequireSession();
		var scene = session.Scene;

		if ( session.IsPlaying )
			throw new InvalidOperationException( "Cannot save while playing - editor_stop first (play-mode changes are discarded by design)" );

		if ( scene is PrefabScene )
			throw new InvalidOperationException( "The active session is a prefab - prefabs save with scene_save, or use prefab_create_from_gameobject" );

		if ( !scenePath.EndsWith( ".scene", StringComparison.OrdinalIgnoreCase ) )
			throw new ArgumentException( "scenePath must end in .scene" );

		var absolute = AssetTools.ResolveNewAssetPath( scenePath );
		System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );

		var asset = AssetSystem.CreateResource( "scene", absolute )
			?? throw new InvalidOperationException( $"Could not create a scene resource at '{scenePath}' - is the path inside the project?" );

		// mirror of SceneEditorSession.Save: Scene.CreateSceneFile() is internal,
		// so reach it via reflection (same flow the editor's own Ctrl+S runs)
		var createSceneFile = typeof( Scene ).GetMethod( "CreateSceneFile",
			System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic )
			?? throw new InvalidOperationException( "Scene.CreateSceneFile not found - the engine changed; report this" );

		var resource = (Sandbox.GameResource)createSceneFile.Invoke( scene, null );
		asset.SaveToDisk( resource );

		// Scene.Source's setter is internal - reflection again, matching the editor's save flow
		typeof( Scene ).GetProperty( "Source" )?.SetValue( scene, resource );
		scene.Name = System.IO.Path.GetFileNameWithoutExtension( absolute );
		session.HasUnsavedChanges = false;

		return new { saved = asset.Path };
	}

	[McpTool( "scene_undo", "Undoes the last editor action.", ToolCategory.Scene, Writes = true )]
	public static object Undo()
	{
		var ok = RequireSession().UndoSystem.Undo();
		return new { undone = ok };
	}

	[McpTool( "scene_redo", "Redoes the last undone editor action.", ToolCategory.Scene, Writes = true )]
	public static object Redo()
	{
		var ok = RequireSession().UndoSystem.Redo();
		return new { redone = ok };
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 48,
    "Files": [
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Registry/McpToolAttribute.cs",
            "FileName": "McpToolAttribute.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\n\r\nnamespace SboxMcp.Registry;\r\n\r\npublic enum ToolCategory\r\n{\r\n\tScene,\r\n\tGameObject,\r\n\tComponent,\r\n\tPrefab,\r\n\tAsset,\r\n\tModelDoc,\r\n\tAnimGraph,\r\n\tShaderGraph,\r\n\tActionGraph,\r\n\tCode,\r\n\tEditor,\r\n\tRetargeter,\r\n\tAnimEditor,\r\n\tCloud,\r\n\tImported\r\n}\r\n\r\n/// <summary>\r\n/// Marks a static method as an MCP tool. The registry reflects the method's\r\n/// parameters into a JSON Schema and exposes it via tools/list.\r\n/// </summary>\r\n[AttributeUsage( AttributeTargets.Method )]\r\npublic sealed class McpToolAttribute : Attribute\r\n{\r\n\tpublic string Name { get; }\r\n\tpublic string Description { get; }\r\n\tpublic ToolCategory Category { get; }\r\n\r\n\t/// <summary>Write tools are subject to the permission gate (approve-writes / read-only modes).</summary>\r\n\tpublic bool Writes { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// Optional requirement key (e.g. an integration's library ident). The host\r\n\t/// resolves it via ToolRegistry.RequirementResolver; unresolved tools are\r\n\t/// hidden from clients and shown disabled in the tool browser.\r\n\t/// </summary>\r\n\tpublic string Requires { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// Ships disabled; the user must enable it in the tool browser. Used for\r\n\t/// tools with external effects (e.g. downloading cloud assets).\r\n\t/// </summary>\r\n\tpublic bool DisabledByDefault { get; init; }\r\n\r\n\tpublic McpToolAttribute( string name, string description, ToolCategory category )\r\n\t{\r\n\t\tName = name;\r\n\t\tDescription = description;\r\n\t\tCategory = category;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Optional description for a tool parameter, surfaced in the JSON Schema.\r\n/// </summary>\r\n[AttributeUsage( AttributeTargets.Parameter )]\r\npublic sealed class DescAttribute : Attribute\r\n{\r\n\tpublic string Text { get; }\r\n\tpublic DescAttribute( string text ) { Text = text; }\r\n}\r\n\r\n/// <summary>\r\n/// Thrown when tool arguments are missing or cannot be bound; surfaced to the\r\n/// MCP client as an isError tool result.\r\n/// </summary>\r\npublic sealed class ToolArgumentException : Exception\r\n{\r\n\tpublic ToolArgumentException( string message, Exception inner = null ) : base( message, inner ) { }\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Registry/ToolRegistry.cs",
            "FileName": "ToolRegistry.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\nusing SboxMcp.Server;\r\n\r\nnamespace SboxMcp.Registry;\r\n\r\n/// <summary>\r\n/// A discovered [McpTool] method, with its generated descriptor and an\r\n/// argument-binding invoker.\r\n/// </summary>\r\npublic sealed class RegisteredTool\r\n{\r\n\tpublic McpToolAttribute Meta { get; }\r\n\tpublic MethodInfo Method { get; }\r\n\tpublic McpToolDescriptor Descriptor { get; }\r\n\r\n\t/// <summary>\r\n\t/// Why this tool cannot run right now (\"Disabled\", \"Not Installed\", ...),\r\n\t/// or null when it is available. Evaluated live so user toggles and\r\n\t/// integrations installed mid-session apply without a restart.\r\n\t/// </summary>\r\n\tpublic string UnavailableReason\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tif ( ToolRegistry.DisabledResolver?.Invoke( this ) ?? Meta.DisabledByDefault )\r\n\t\t\t\treturn \"Disabled\";\r\n\r\n\t\t\treturn Meta.Requires is null ? null : ToolRegistry.RequirementResolver?.Invoke( Meta.Requires );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic bool IsAvailable => UnavailableReason is null;\r\n\r\n\tinternal RegisteredTool( McpToolAttribute meta, MethodInfo method )\r\n\t{\r\n\t\tMeta = meta;\r\n\t\tMethod = method;\r\n\t\tDescriptor = new McpToolDescriptor( meta.Name, BuildDescription( meta ), SchemaGenerator.ForMethod( method ) );\r\n\t}\r\n\r\n\tstatic string BuildDescription( McpToolAttribute meta ) =>\r\n\t\tmeta.Writes ? $\"{meta.Description} (modifies project state)\" : meta.Description;\r\n\r\n\t/// <summary>\r\n\t/// Binds JSON arguments to the method's parameters by name and invokes it.\r\n\t/// Throws ToolArgumentException on missing/unbindable arguments.\r\n\t/// </summary>\r\n\tpublic object Invoke( JsonElement? args )\r\n\t{\r\n\t\tvar parameters = Method.GetParameters();\r\n\t\tvar bound = new object[parameters.Length];\r\n\r\n\t\tfor ( var i = 0; i < parameters.Length; i++ )\r\n\t\t{\r\n\t\t\tvar p = parameters[i];\r\n\r\n\t\t\t// JsonElement params accept explicit null (e.g. to clear a reference\r\n\t\t\t// property); for typed params null falls through to the default\r\n\t\t\tif ( args is { ValueKind: JsonValueKind.Object } a && a.TryGetProperty( p.Name, out var value )\r\n\t\t\t\t&& (value.ValueKind != JsonValueKind.Null || p.ParameterType == typeof( JsonElement )) )\r\n\t\t\t{\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tbound[i] = p.ParameterType == typeof( JsonElement )\r\n\t\t\t\t\t\t? value.Clone()\r\n\t\t\t\t\t\t: value.Deserialize( p.ParameterType, ToolRegistry.BindOptions );\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( Exception e ) when ( e is JsonException or NotSupportedException )\r\n\t\t\t\t{\r\n\t\t\t\t\tthrow new ToolArgumentException(\r\n\t\t\t\t\t\t$\"Argument '{p.Name}' could not be read as {p.ParameterType.Name}: {e.Message}\", e );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse if ( p.HasDefaultValue )\r\n\t\t\t{\r\n\t\t\t\tbound[i] = p.DefaultValue;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tthrow new ToolArgumentException( $\"Missing required argument '{p.Name}'\" );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\treturn Method.Invoke( null, bound );\r\n\t\t}\r\n\t\tcatch ( TargetInvocationException e ) when ( e.InnerException is not null )\r\n\t\t{\r\n\t\t\tthrow e.InnerException;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Discovers [McpTool] static methods and serves them to the MCP server.\r\n/// </summary>\r\npublic sealed class ToolRegistry\r\n{\r\n\t/// <summary>\r\n\t/// Maps a tool's Requires key to an unavailability reason (short, e.g.\r\n\t/// \"Not Installed\") or null when the requirement is satisfied. Null\r\n\t/// resolver = everything available.\r\n\t/// </summary>\r\n\tpublic static Func<string, string> RequirementResolver { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Whether the user has disabled this tool. Null resolver = only\r\n\t/// DisabledByDefault applies.\r\n\t/// </summary>\r\n\tpublic static Func<RegisteredTool, bool> DisabledResolver { get; set; }\r\n\r\n\tinternal static readonly JsonSerializerOptions BindOptions = new()\r\n\t{\r\n\t\tPropertyNameCaseInsensitive = true,\r\n\t\tConverters = { new JsonStringEnumConverter() }\r\n\t};\r\n\r\n\tstatic readonly JsonSerializerOptions ResultOptions = new()\r\n\t{\r\n\t\tWriteIndented = true,\r\n\t\tConverters = { new JsonStringEnumConverter() }\r\n\t};\r\n\r\n\treadonly List<RegisteredTool> _tools = new();\r\n\treadonly Dictionary<string, RegisteredTool> _byName = new( StringComparer.Ordinal );\r\n\r\n\tpublic IReadOnlyList<RegisteredTool> Tools => _tools;\r\n\r\n\tpublic void AddAssembly( Assembly assembly )\r\n\t{\r\n\t\tvar methods = assembly.GetTypes()\r\n\t\t\t.Where( t => t.IsClass )\r\n\t\t\t.SelectMany( t => t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )\r\n\t\t\t.Select( m => (Method: m, Meta: m.GetCustomAttribute<McpToolAttribute>()) )\r\n\t\t\t.Where( x => x.Meta is not null )\r\n\t\t\t.OrderBy( x => x.Meta.Name, StringComparer.Ordinal );\r\n\r\n\t\tforeach ( var (method, meta) in methods )\r\n\t\t{\r\n\t\t\tif ( _byName.ContainsKey( meta.Name ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar tool = new RegisteredTool( meta, method );\r\n\t\t\t_tools.Add( tool );\r\n\t\t\t_byName[meta.Name] = tool;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic RegisteredTool Find( string name ) => _byName.GetValueOrDefault( name );\r\n\r\n\t/// <summary>\r\n\t/// Registers an arbitrary public static method (from another library) as a\r\n\t/// tool. Returns null when the name is already taken.\r\n\t/// </summary>\r\n\tpublic RegisteredTool AddImported( string name, string description, ToolCategory category, MethodInfo method )\r\n\t{\r\n\t\tif ( _byName.ContainsKey( name ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar meta = new McpToolAttribute( name, description, category ) { Writes = true };\r\n\t\tvar tool = new RegisteredTool( meta, method );\r\n\t\t_tools.Add( tool );\r\n\t\t_byName[name] = tool;\r\n\t\treturn tool;\r\n\t}\r\n\r\n\tpublic void Remove( string name )\r\n\t{\r\n\t\tif ( _byName.Remove( name, out var tool ) )\r\n\t\t\t_tools.Remove( tool );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Converts a tool's return value to the text sent back to the client.\r\n\t/// </summary>\r\n\tpublic static string FormatResult( object result ) => result switch\r\n\t{\r\n\t\tnull => \"\"\"{ \"ok\": true }\"\"\",\r\n\t\tstring s => s,\r\n\t\t_ => JsonSerializer.Serialize( result, ResultOptions )\r\n\t};\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/PrefabTools.cs",
            "FileName": "PrefabTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Registry;\r\nusing static SboxMcp.Tools.ToolHelpers;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\npublic static class PrefabTools\r\n{\r\n\t[McpTool( \"prefab_instantiate\", \"Instantiates a prefab into the active scene.\", ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object Instantiate(\r\n\t\t[Desc( \"Prefab asset path, e.g. 'prefabs/door.prefab'\" )] string prefabPath,\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tvar prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )\r\n\t\t\t?? throw new InvalidOperationException( $\"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'\" );\r\n\r\n\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t?? throw new InvalidOperationException( $\"Prefab '{prefabPath}' could not be loaded\" );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: instantiate {prefabPath}\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar transform = position is null\r\n\t\t\t? global::Transform.Zero\r\n\t\t\t: new Transform( ToVector3( position, \"position\" ) );\r\n\r\n\t\tvar instance = prefabScene.Clone( transform );\r\n\t\treturn Describe( instance );\r\n\t}\r\n\r\n\t[McpTool( \"prefab_instantiate_many\", \"Instantiates a prefab at many world positions in one call - populate a level efficiently (a forest of trees, a row of enemies, scattered pickups). Returns the created instance ids.\", ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object InstantiateMany(\r\n\t\t[Desc( \"Prefab asset path, e.g. 'prefabs/tree.prefab'\" )] string prefabPath,\r\n\t\t[Desc( \"World positions, each [x, y, z]\" )] float[][] positions )\r\n\t{\r\n\t\tif ( positions is null || positions.Length == 0 )\r\n\t\t\tthrow new ArgumentException( \"Pass at least one position\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tvar prefabFile = ResourceLibrary.Get<PrefabFile>( prefabPath )\r\n\t\t\t?? throw new InvalidOperationException( $\"No prefab at '{prefabPath}' - use asset_search with assetType 'prefab'\" );\r\n\r\n\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t?? throw new InvalidOperationException( $\"Prefab '{prefabPath}' could not be loaded\" );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: instantiate {positions.Length}x {prefabPath}\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar instances = new List<object>();\r\n\t\tforeach ( var pos in positions )\r\n\t\t{\r\n\t\t\tvar instance = prefabScene.Clone( new Transform( ToVector3( pos, \"position\" ) ) );\r\n\t\t\tinstances.Add( new { id = instance.Id, name = instance.Name, position = pos } );\r\n\t\t}\r\n\r\n\t\treturn new { prefab = prefabPath, count = instances.Count, instances };\r\n\t}\r\n\r\n\t[McpTool( \"prefab_create_from_gameobject\", \"Turns a GameObject (and its children) into a reusable .prefab asset; the original becomes an instance of it.\", ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object CreateFromGameObject(\r\n\t\t[Desc( \"GameObject id or unique name\" )] string gameObject,\r\n\t\t[Desc( \"Output path ending in .prefab, e.g. 'prefabs/door.prefab'\" )] string prefabPath )\r\n\t{\r\n\t\tif ( !prefabPath.EndsWith( \".prefab\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new ArgumentException( \"prefabPath must end in .prefab\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\t\tvar absolute = AssetTools.ResolveNewAssetPath( prefabPath );\r\n\r\n\t\tif ( System.IO.File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\"'{prefabPath}' already exists\" );\r\n\r\n\t\tSystem.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: create prefab {prefabPath}\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();\r\n\r\n\t\tEditorUtility.Prefabs.ConvertGameObjectToPrefab( go, absolute );\r\n\r\n\t\treturn new { created = prefabPath, instanceId = go.Id };\r\n\t}\r\n\r\n\t[McpTool( \"prefab_break_instance\", \"Unlinks a prefab instance so it becomes plain GameObjects.\", ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object BreakInstance( [Desc( \"GameObject id or unique name of the prefab instance root\" )] string gameObject )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tif ( !go.IsPrefabInstance )\r\n\t\t\tthrow new InvalidOperationException( $\"'{go.Name}' is not a prefab instance\" );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: break prefab instance\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();\r\n\r\n\t\tgo.BreakFromPrefab();\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"prefab_update_from_prefab\", \"Re-syncs a prefab instance from its source prefab file.\", ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object UpdateFromPrefab( [Desc( \"GameObject id or unique name of the prefab instance root\" )] string gameObject )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tif ( !go.IsPrefabInstance )\r\n\t\t\tthrow new InvalidOperationException( $\"'{go.Name}' is not a prefab instance\" );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: update from prefab\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();\r\n\r\n\t\tgo.UpdateFromPrefab();\r\n\t\treturn Describe( go );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/ServerTools.cs",
            "FileName": "ServerTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing SboxMcp.Integration;\r\nusing SboxMcp.Registry;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\n/// <summary>\r\n/// Tools that operate on the MCP server itself: batch execution (many calls in\r\n/// one request) and reading/adjusting the server's own configuration.\r\n/// </summary>\r\npublic static class ServerTools\r\n{\r\n\t[McpTool( \"batch\", \"Runs several tool calls in one request, in order - big speedup for multi-step builds (create object, add component, set properties...). Each step is {name, arguments}. Stops on the first error unless continueOnError is true.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object Batch(\r\n\t\t[Desc( \"JSON array of steps, e.g. [{\\\"name\\\":\\\"gameobject_create\\\",\\\"arguments\\\":{\\\"name\\\":\\\"X\\\"}}, ...]\" )] JsonElement steps,\r\n\t\t[Desc( \"Keep going after a step fails instead of stopping\" )] bool continueOnError = false )\r\n\t{\r\n\t\tif ( steps.ValueKind != JsonValueKind.Array )\r\n\t\t\tthrow new ArgumentException( \"steps must be a JSON array of {name, arguments} objects\" );\r\n\r\n\t\tvar registry = McpHost.Registry\r\n\t\t\t?? throw new InvalidOperationException( \"Server not initialized\" );\r\n\r\n\t\tvar results = new List<object>();\r\n\t\tvar index = 0;\r\n\r\n\t\tforeach ( var step in steps.EnumerateArray() )\r\n\t\t{\r\n\t\t\tindex++;\r\n\r\n\t\t\tif ( !step.TryGetProperty( \"name\", out var nameEl ) || nameEl.ValueKind != JsonValueKind.String )\r\n\t\t\t{\r\n\t\t\t\tresults.Add( new { step = index, ok = false, error = \"step is missing a string 'name'\" } );\r\n\t\t\t\tif ( !continueOnError ) break; else continue;\r\n\t\t\t}\r\n\r\n\t\t\tvar name = nameEl.GetString();\r\n\t\t\tvar tool = registry.Find( name );\r\n\r\n\t\t\tif ( tool is null || !tool.IsAvailable )\r\n\t\t\t{\r\n\t\t\t\tresults.Add( new { step = index, name, ok = false, error = tool is null ? \"unknown tool\" : $\"unavailable: {tool.UnavailableReason}\" } );\r\n\t\t\t\tif ( !continueOnError ) break; else continue;\r\n\t\t\t}\r\n\r\n\t\t\tJsonElement? args = step.TryGetProperty( \"arguments\", out var a ) && a.ValueKind == JsonValueKind.Object ? a : null;\r\n\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\t// already on the editor main thread (batch itself was dispatched there)\r\n\t\t\t\tvar result = tool.Invoke( args );\r\n\r\n\t\t\t\t// async tools (cloud_*) return a Task - can't be awaited on the\r\n\t\t\t\t// main thread without freezing the editor, so reject clearly\r\n\t\t\t\tif ( result is System.Threading.Tasks.Task )\r\n\t\t\t\t{\r\n\t\t\t\t\tresults.Add( new { step = index, name, ok = false, error = \"this tool is async and cannot run inside a batch - call it on its own\" } );\r\n\t\t\t\t\tLogStep( tool, args, true, \"async tool skipped\" );\r\n\t\t\t\t\tif ( !continueOnError ) break; else continue;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tresults.Add( new { step = index, name, ok = true, result } );\r\n\t\t\t\tLogStep( tool, args, false, null );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tresults.Add( new { step = index, name, ok = false, error = e.Message } );\r\n\t\t\t\tLogStep( tool, args, false, e.Message );\r\n\t\t\t\tif ( !continueOnError ) break;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar ran = results.Count;\r\n\t\tvar failed = results.Count( r => r.GetType().GetProperty( \"ok\" )?.GetValue( r ) is false );\r\n\t\treturn new { requested = steps.GetArrayLength(), ran, failed, results };\r\n\t}\r\n\r\n\t// each batch step gets its own activity-feed entry (so revert/audit work per-step)\r\n\tstatic void LogStep( RegisteredTool tool, JsonElement? args, bool skipped, string error )\r\n\t{\r\n\t\tActivityLog.Record( new ActivityRecord\r\n\t\t{\r\n\t\t\tToolName = $\"batch:{tool.Meta.Name}\",\r\n\t\t\tCategory = tool.Meta.Category,\r\n\t\t\tArgsDigest = PermissionGate.Summarize( args ),\r\n\t\t\tOk = error is null && !skipped,\r\n\t\t\tError = error\r\n\t\t} );\r\n\t}\r\n\r\n\t[McpTool( \"server_get_config\", \"Reads the MCP server's current configuration: port, permission mode, autostart, tool counts.\", ToolCategory.Editor )]\r\n\tpublic static object GetConfig()\r\n\t{\r\n\t\tvar registry = McpHost.Registry;\r\n\t\tvar tools = registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>();\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\turl = McpHost.Server?.Url,\r\n\t\t\trunning = McpHost.Server?.IsRunning ?? false,\r\n\t\t\tport = McpSettings.Port,\r\n\t\t\tautoStart = McpSettings.AutoStart,\r\n\t\t\tpermissionMode = McpSettings.Mode.ToString(),\r\n\t\t\ttoolCount = tools.Count,\r\n\t\t\tenabledTools = tools.Count( t => t.IsAvailable ),\r\n\t\t\tconnectedClients = McpHost.Server?.Sessions.Count ?? 0,\r\n\t\t\tnote = \"Permission mode is set by the user in the dashboard and cannot be changed over MCP by design.\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"server_set_config\", \"Adjusts server settings the AI is allowed to change (port, autostart). Permission mode stays user-only. Changing the port restarts the listener.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object SetConfig(\r\n\t\t[Desc( \"New port 1024-65535; omit to leave unchanged\" )] int? port = null,\r\n\t\t[Desc( \"Autostart on editor load; omit to leave unchanged\" )] bool? autoStart = null )\r\n\t{\r\n\t\tvar restarted = false;\r\n\r\n\t\tif ( autoStart is bool a )\r\n\t\t\tMcpSettings.AutoStart = a;\r\n\r\n\t\tif ( port is int p )\r\n\t\t{\r\n\t\t\tif ( p is < 1024 or > 65535 )\r\n\t\t\t\tthrow new ArgumentException( \"port must be 1024..65535\" );\r\n\r\n\t\t\tif ( p != McpSettings.Port )\r\n\t\t\t{\r\n\t\t\t\tMcpSettings.Port = p;\r\n\t\t\t\tMcpHost.Restart();\r\n\t\t\t\trestarted = true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn new { port = McpSettings.Port, autoStart = McpSettings.AutoStart, restarted, note = restarted ? \"Listener restarted on the new port - reconnect your client.\" : \"Updated.\" };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/UI/ImportToolsDialog.cs",
            "FileName": "ImportToolsDialog.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Integration;\r\n\r\nnamespace SboxMcp.UI;\r\n\r\n/// <summary>\r\n/// Pick public static methods from installed libraries (and other loaded\r\n/// code) to expose as MCP tools. Searchable; libraries are listed separately\r\n/// from everything else. Choices apply immediately and persist.\r\n/// </summary>\r\npublic class ImportToolsDialog : Dialog\r\n{\r\n\treadonly LineEdit _search;\r\n\treadonly ScrollArea _scroll;\r\n\r\n\tpublic ImportToolsDialog( Widget parent ) : base( parent )\r\n\t{\r\n\t\tWindow.WindowTitle = \"Import Tools From Library\";\r\n\t\tWindow.SetWindowIcon( \"library_add\" );\r\n\t\tWindow.SetModal( true, true );\r\n\t\tWindow.MinimumWidth = 560;\r\n\t\tWindow.MinimumHeight = 480;\r\n\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 16;\r\n\t\tLayout.Spacing = 8;\r\n\r\n\t\tvar hint = Layout.Add( new Label(\r\n\t\t\t\"Expose public static methods from installed libraries as MCP tools. \"\r\n\t\t\t+ \"Imported tools persist, re-bind every session, and are write-gated by approvals.\", this ) );\r\n\t\thint.SetStyles( $\"color: {Theme.TextLight.Hex}; font-size: 11px;\" );\r\n\t\thint.WordWrap = true;\r\n\r\n\t\t_search = Layout.Add( new LineEdit( this ) { PlaceholderText = \"Search methods, types or libraries...\" } );\r\n\t\t_search.TextEdited += _ => Rebuild();\r\n\r\n\t\t_scroll = new ScrollArea( this );\r\n\t\t_scroll.Canvas = new Widget( _scroll );\r\n\t\t_scroll.Canvas.Layout = Layout.Column();\r\n\t\t_scroll.Canvas.Layout.Spacing = 2;\r\n\t\t_scroll.Canvas.Layout.Margin = 4;\r\n\t\t_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;\r\n\t\t_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;\r\n\t\tLayout.Add( _scroll, 1 );\r\n\r\n\t\tvar buttons = Layout.AddRow();\r\n\t\tbuttons.AddStretchCell();\r\n\t\tvar done = buttons.Add( new Button.Primary( \"Done\" ) { Icon = \"check\" } );\r\n\t\tdone.Clicked = Close; // Dialog.Close closes the host window (Destroy leaves it black)\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\tvar canvas = _scroll.Canvas;\r\n\t\tcanvas.Layout.Clear( true );\r\n\r\n\t\tvar query = _search.Text;\r\n\t\tvar candidates = ToolImporter.CandidateAssemblies().ToList();\r\n\r\n\t\tAddSection( canvas, \"Libraries\", \"extension\",\r\n\t\t\tcandidates.Where( ToolImporter.IsLibraryAssembly ).ToList(), query );\r\n\r\n\t\tAddSection( canvas, \"Project & Other\", \"folder\",\r\n\t\t\tcandidates.Where( a => !ToolImporter.IsLibraryAssembly( a ) ).ToList(), query );\r\n\r\n\t\tcanvas.Layout.AddStretchCell();\r\n\t}\r\n\r\n\tvoid AddSection( Widget canvas, string title, string icon, List<Assembly> assemblies, string query )\r\n\t{\r\n\t\tvar header = canvas.Layout.Add( new Label( title, canvas ) );\r\n\t\theader.SetStyles( $\"color: {Theme.Blue.Hex}; font-size: 12px; font-weight: 700; margin-top: 8px;\" );\r\n\r\n\t\tvar any = false;\r\n\r\n\t\tforeach ( var assembly in assemblies )\r\n\t\t{\r\n\t\t\tvar methods = ToolImporter.CandidateMethods( assembly )\r\n\t\t\t\t.Where( m => Matches( assembly, m, query ) )\r\n\t\t\t\t.Take( 60 )\r\n\t\t\t\t.ToList();\r\n\r\n\t\t\tif ( methods.Count == 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tany = true;\r\n\r\n\t\t\tvar name = canvas.Layout.Add( new Label( ToolImporter.FriendlyName( assembly ), canvas ) );\r\n\t\t\tname.SetStyles( $\"color: {Theme.Text.Hex}; font-size: 11px; font-weight: 600; margin-top: 4px; margin-left: 6px;\" );\r\n\r\n\t\t\tforeach ( var method in methods )\r\n\t\t\t{\r\n\t\t\t\tvar parameters = string.Join( \", \", method.GetParameters().Select( p => p.Name ) );\r\n\t\t\t\tvar check = canvas.Layout.Add( new Checkbox( $\"{method.DeclaringType?.Name}.{method.Name}({parameters})\", canvas )\r\n\t\t\t\t{\r\n\t\t\t\t\tValue = ToolImporter.IsImported( method )\r\n\t\t\t\t} );\r\n\t\t\t\tcheck.ToolTip = method.DeclaringType?.FullName;\r\n\r\n\t\t\t\tvar captured = method;\r\n\t\t\t\tcheck.Clicked = () =>\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( check.Value )\r\n\t\t\t\t\t\tToolImporter.Import( captured );\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tToolImporter.Unimport( captured );\r\n\t\t\t\t};\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !any )\r\n\t\t{\r\n\t\t\tvar empty = canvas.Layout.Add( new Label(\r\n\t\t\t\tstring.IsNullOrWhiteSpace( query ) ? \"Nothing importable found.\" : \"No matches.\", canvas ) );\r\n\t\t\tempty.SetStyles( $\"color: {Theme.TextLight.Hex}; font-size: 11px; margin-left: 6px;\" );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic bool Matches( Assembly assembly, MethodInfo method, string query )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( query ) )\r\n\t\t\treturn true;\r\n\r\n\t\treturn method.Name.Contains( query, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t|| (method.DeclaringType?.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) ?? false)\r\n\t\t\t|| ToolImporter.FriendlyName( assembly ).Contains( query, StringComparison.OrdinalIgnoreCase );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/UI/Pages/ToolsPage.cs",
            "FileName": "ToolsPage.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Integration;\r\nusing SboxMcp.Registry;\r\n\r\nnamespace SboxMcp.UI;\r\n\r\n/// <summary>\r\n/// Searchable, category-filterable browser of every tool the server exposes.\r\n/// Doubles as documentation.\r\n/// </summary>\r\npublic class ToolsPage : Widget\r\n{\r\n\treadonly LineEdit _search;\r\n\treadonly List<CategoryChip> _chips = new();\r\n\treadonly ScrollArea _scroll;\r\n\r\n\tint _builtSignature = -1;\r\n\r\n\tpublic ToolsPage( Widget parent ) : base( parent )\r\n\t{\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Margin = 12;\r\n\t\tLayout.Spacing = 8;\r\n\r\n\t\tvar searchRow = Layout.AddRow();\r\n\t\tsearchRow.Spacing = 6;\r\n\r\n\t\t_search = searchRow.Add( new LineEdit( this ) { PlaceholderText = \"Search tools...\" }, 1 );\r\n\t\t_search.TextEdited += _ => Rebuild();\r\n\r\n\t\tvar import = searchRow.Add( new Button( \"Import Tools\", \"library_add\" ) );\r\n\t\timport.ToolTip = \"Expose public static methods from other installed libraries as MCP tools\";\r\n\t\timport.Clicked = () => new ImportToolsDialog( this ).Show();\r\n\r\n\t\t// FlowRow wraps the chips to new lines on narrow docks instead of\r\n\t\t// letting them overlap\r\n\t\tvar chipFlow = Layout.Add( new FlowRow( this ) );\r\n\r\n\t\tforeach ( var category in Enum.GetValues<ToolCategory>() )\r\n\t\t{\r\n\t\t\tvar chip = new CategoryChip( category, chipFlow, clickable: true );\r\n\t\t\tchip.OnToggled = Rebuild;\r\n\t\t\t_chips.Add( chip );\r\n\t\t\tchipFlow.AddItem( chip );\r\n\t\t}\r\n\r\n\t\t_scroll = new ScrollArea( this );\r\n\t\t_scroll.Canvas = new Widget( _scroll );\r\n\t\t_scroll.Canvas.Layout = Layout.Column();\r\n\t\t_scroll.Canvas.Layout.Spacing = 2;\r\n\t\t_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;\r\n\t\t_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;\r\n\t\tLayout.Add( _scroll, 1 );\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The dock restores before McpHost initializes, so the registry is empty\r\n\t/// at construction time - poll until tools appear.\r\n\t/// </summary>\r\n\tpublic void Tick()\r\n\t{\r\n\t\tvar sig = Signature();\r\n\t\tif ( sig == _builtSignature )\r\n\t\t\treturn;\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tstatic int Signature()\r\n\t{\r\n\t\tvar tools = McpHost.Registry?.Tools;\r\n\t\treturn tools is null ? 0 : tools.Count * 1000 + tools.Count( t => t.IsAvailable );\r\n\t}\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\t_builtSignature = Signature();\r\n\r\n\t\tvar canvas = _scroll.Canvas;\r\n\t\tcanvas.Layout.Clear( true );\r\n\r\n\t\tvar query = _search.Text;\r\n\t\tvar enabled = _chips.Where( c => c.Toggled ).Select( c => c.Category ).ToHashSet();\r\n\r\n\t\tvar tools = (McpHost.Registry?.Tools ?? (IReadOnlyList<RegisteredTool>)Array.Empty<RegisteredTool>())\r\n\t\t\t.Where( t => enabled.Contains( t.Meta.Category ) )\r\n\t\t\t.Where( t => string.IsNullOrWhiteSpace( query )\r\n\t\t\t\t|| t.Meta.Name.Contains( query, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t\t|| t.Meta.Description.Contains( query, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.ToList();\r\n\r\n\t\tvar count = canvas.Layout.Add( new Label( $\"{tools.Count} tools\", canvas ) );\r\n\t\tcount.SetStyles( $\"color: {Palette.TextDim.Hex}; font-size: 10px;\" );\r\n\r\n\t\tforeach ( var tool in tools )\r\n\t\t\tcanvas.Layout.Add( new ToolRow( tool, canvas ) );\r\n\r\n\t\tcanvas.Layout.AddStretchCell();\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// One tool entry: name (mono), write badge, wrapped description.\r\n/// </summary>\r\npublic class ToolRow : Widget\r\n{\r\n\tconst float ToggleWidth = 40;\r\n\r\n\treadonly RegisteredTool _tool;\r\n\r\n\tpublic ToolRow( RegisteredTool tool, Widget parent ) : base( parent )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\tFixedHeight = 40;\r\n\t\tToolTip = tool.Meta.Description + \"\\n\\nClick the toggle to enable/disable this tool.\";\r\n\t}\r\n\r\n\tbool UserDisabled => McpSettings.GetToolDisabledOverride( _tool.Meta.Name ) ?? _tool.Meta.DisabledByDefault;\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseClick( e );\r\n\r\n\t\tif ( e.RightMouseButton )\r\n\t\t\treturn;\r\n\r\n\t\t// the toggle lives in the right strip of the row\r\n\t\tif ( e.LocalPosition.x < LocalRect.Right - ToggleWidth )\r\n\t\t\treturn;\r\n\r\n\t\tMcpSettings.SetToolDisabled( _tool.Meta.Name, !UserDisabled );\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\r\n\t\tvar unavailable = _tool.UnavailableReason;\r\n\t\tvar disabled = unavailable is not null;\r\n\t\tvar accent = Palette.For( _tool.Meta.Category );\r\n\r\n\t\tif ( disabled )\r\n\t\t\taccent = accent.WithAlpha( 0.35f );\r\n\r\n\t\tif ( Paint.HasMouseOver && !disabled )\r\n\t\t{\r\n\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.03f ) );\r\n\t\t\tPaint.DrawRect( LocalRect, 5 );\r\n\t\t}\r\n\r\n\t\t// category color tick\r\n\t\tPaint.SetBrush( accent );\r\n\t\tPaint.DrawRect( new Rect( LocalRect.Left + 2, LocalRect.Top + 8, 3, LocalRect.Height - 16 ), 1.5f );\r\n\r\n\t\t// name\r\n\t\tPaint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.6f ) : Palette.TextBright );\r\n\t\tPaint.SetFont( \"Consolas\", 8, 600 );\r\n\t\tvar nameWidth = Paint.MeasureText( _tool.Meta.Name ).x;\r\n\t\tPaint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 4, nameWidth + 4, 14 ), _tool.Meta.Name, TextFlag.LeftCenter );\r\n\r\n\t\tvar badgeLeft = LocalRect.Left + 20 + nameWidth;\r\n\r\n\t\t// writes badge\r\n\t\tif ( _tool.Meta.Writes && !disabled )\r\n\t\t{\r\n\t\t\tvar badge = new Rect( badgeLeft, LocalRect.Top + 5, 44, 13 );\r\n\t\t\tPaint.SetBrush( Palette.Error.WithAlpha( 0.18f ) );\r\n\t\t\tPaint.DrawRect( badge, 6 );\r\n\t\t\tPaint.SetPen( Palette.Error );\r\n\t\t\tPaint.SetDefaultFont( 6, 700 );\r\n\t\t\tPaint.DrawText( badge, \"WRITES\", TextFlag.Center );\r\n\t\t}\r\n\r\n\t\t// unavailable badge, e.g. \"Not Installed\"\r\n\t\tif ( disabled )\r\n\t\t{\r\n\t\t\tPaint.SetDefaultFont( 6, 700 );\r\n\t\t\tvar badgeWidth = Paint.MeasureText( unavailable ).x + 12;\r\n\t\t\tvar badge = new Rect( badgeLeft, LocalRect.Top + 5, badgeWidth, 13 );\r\n\t\t\tPaint.SetBrush( Palette.TextDim.WithAlpha( 0.15f ) );\r\n\t\t\tPaint.DrawRect( badge, 6 );\r\n\t\t\tPaint.SetPen( Palette.TextDim );\r\n\t\t\tPaint.DrawText( badge, unavailable, TextFlag.Center );\r\n\t\t}\r\n\r\n\t\t// description\r\n\t\tPaint.SetPen( disabled ? Palette.TextDim.WithAlpha( 0.5f ) : Palette.TextDim );\r\n\t\tPaint.SetDefaultFont( 7 );\r\n\t\tPaint.DrawText( new Rect( LocalRect.Left + 14, LocalRect.Top + 20, LocalRect.Width - ToggleWidth - 20, 14 ),\r\n\t\t\t_tool.Meta.Description, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\r\n\t\t// enable/disable toggle (persisted per tool)\r\n\t\tvar off = UserDisabled;\r\n\t\tPaint.SetPen( off ? Palette.TextDim : Theme.Green );\r\n\t\tPaint.DrawIcon( new Rect( LocalRect.Right - ToggleWidth, LocalRect.Top, ToggleWidth - 8, LocalRect.Height ),\r\n\t\t\toff ? \"toggle_off\" : \"toggle_on\", 22, TextFlag.Center );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Integration/LogCapture.cs",
            "FileName": "LogCapture.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.RegularExpressions;\r\nusing Sandbox;\r\n\r\nnamespace SboxMcp.Integration;\r\n\r\npublic sealed class CapturedLog\r\n{\r\n\tpublic long Seq { get; init; }\r\n\tpublic DateTime Time { get; init; } = DateTime.Now;\r\n\tpublic string Level { get; init; }\r\n\tpublic string Logger { get; init; }\r\n\tpublic string Message { get; init; }\r\n\tpublic string Stack { get; init; }\r\n\tpublic bool IsDiagnostic { get; init; }\r\n}\r\n\r\n/// <summary>\r\n/// Subscribes to the engine log stream so tools can read recent console\r\n/// output (including compile diagnostics, which the editor logs). Each entry\r\n/// gets a monotonic sequence number so callers can poll incrementally with a\r\n/// \"since\" cursor instead of re-reading old entries.\r\n/// </summary>\r\npublic static class LogCapture\r\n{\r\n\tconst int Capacity = 4000;\r\n\r\n\tstatic readonly LinkedList<CapturedLog> _logs = new();\r\n\tstatic long _nextSeq;\r\n\tstatic bool _hooked;\r\n\r\n\tpublic static void Start()\r\n\t{\r\n\t\tif ( _hooked )\r\n\t\t\treturn;\r\n\r\n\t\t_hooked = true;\r\n\t\tEditor.EditorUtility.AddLogger( OnMessage );\r\n\t}\r\n\r\n\tpublic static void Stop()\r\n\t{\r\n\t\tif ( !_hooked )\r\n\t\t\treturn;\r\n\r\n\t\t_hooked = false;\r\n\t\tEditor.EditorUtility.RemoveLogger( OnMessage );\r\n\t}\r\n\r\n\t/// <summary>The sequence number of the newest captured entry (0 if none).\r\n\t/// Pass it back as `sinceSeq` next call to get only what's new.</summary>\r\n\tpublic static long LatestSeq\r\n\t{\r\n\t\tget { lock ( _logs ) return _nextSeq; }\r\n\t}\r\n\r\n\tstatic void OnMessage( LogEvent ev )\r\n\t{\r\n\t\tlock ( _logs )\r\n\t\t{\r\n\t\t\tvar entry = new CapturedLog\r\n\t\t\t{\r\n\t\t\t\tSeq = ++_nextSeq,\r\n\t\t\t\tLevel = ev.Level.ToString(),\r\n\t\t\t\tLogger = ev.Logger,\r\n\t\t\t\tMessage = ev.Message,\r\n\t\t\t\tStack = ev.Stack,\r\n\t\t\t\tIsDiagnostic = ev.IsDiagnostic\r\n\t\t\t};\r\n\r\n\t\t\t_logs.AddFirst( entry );\r\n\t\t\twhile ( _logs.Count > Capacity )\r\n\t\t\t\t_logs.RemoveLast();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Newest-first recent entries, optionally only those newer than\r\n\t/// <paramref name=\"sinceSeq\"/> (the incremental cursor).</summary>\r\n\tpublic static IReadOnlyList<CapturedLog> Recent( int count, string minLevel = null, bool diagnosticsOnly = false, long sinceSeq = 0 )\r\n\t{\r\n\t\tvar threshold = Rank( minLevel );\r\n\r\n\t\tlock ( _logs )\r\n\t\t{\r\n\t\t\treturn _logs\r\n\t\t\t\t.Where( l => l.Seq > sinceSeq )\r\n\t\t\t\t.Where( l => Rank( l.Level ) >= threshold )\r\n\t\t\t\t.Where( l => !diagnosticsOnly || l.IsDiagnostic )\r\n\t\t\t\t.Take( count )\r\n\t\t\t\t.ToArray();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Regex/severity/time-filtered search over the buffer.</summary>\r\n\tpublic static IReadOnlyList<CapturedLog> Search( string pattern, string minLevel, int max, DateTime? since )\r\n\t{\r\n\t\tvar threshold = Rank( minLevel );\r\n\t\tRegex rx = string.IsNullOrEmpty( pattern ) ? null : new Regex( pattern, RegexOptions.IgnoreCase );\r\n\r\n\t\tlock ( _logs )\r\n\t\t{\r\n\t\t\treturn _logs\r\n\t\t\t\t.Where( l => Rank( l.Level ) >= threshold )\r\n\t\t\t\t.Where( l => since is null || l.Time >= since.Value )\r\n\t\t\t\t.Where( l => rx is null || (l.Message is not null && rx.IsMatch( l.Message )) )\r\n\t\t\t\t.Take( max )\r\n\t\t\t\t.ToArray();\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static void Clear()\r\n\t{\r\n\t\tlock ( _logs ) _logs.Clear();\r\n\t}\r\n\r\n\tstatic int Rank( string level ) => level?.ToLowerInvariant() switch\r\n\t{\r\n\t\t\"error\" => 4,\r\n\t\t\"warn\" or \"warning\" => 3,\r\n\t\t\"info\" => 2,\r\n\t\t\"debug\" or \"trace\" => 1,\r\n\t\t_ => 0\r\n\t};\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Registry/SchemaGenerator.cs",
            "FileName": "SchemaGenerator.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Nodes;\r\n\r\nnamespace SboxMcp.Registry;\r\n\r\n/// <summary>\r\n/// Reflects a tool method's parameters into a JSON Schema object.\r\n/// </summary>\r\npublic static class SchemaGenerator\r\n{\r\n\tpublic static JsonElement ForMethod( MethodInfo method )\r\n\t{\r\n\t\tvar properties = new JsonObject();\r\n\t\tvar required = new JsonArray();\r\n\r\n\t\tforeach ( var p in method.GetParameters() )\r\n\t\t{\r\n\t\t\tvar prop = ForType( p.ParameterType );\r\n\r\n\t\t\tvar desc = p.GetCustomAttribute<DescAttribute>()?.Text;\r\n\t\t\tif ( desc is not null )\r\n\t\t\t\tprop[\"description\"] = desc;\r\n\r\n\t\t\tif ( p.HasDefaultValue )\r\n\t\t\t{\r\n\t\t\t\tif ( p.DefaultValue is not null )\r\n\t\t\t\t\tprop[\"default\"] = JsonValue.Create( p.DefaultValue is Enum e ? e.ToString() : p.DefaultValue );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\trequired.Add( p.Name );\r\n\t\t\t}\r\n\r\n\t\t\tproperties[p.Name] = prop;\r\n\t\t}\r\n\r\n\t\tvar schema = new JsonObject\r\n\t\t{\r\n\t\t\t[\"type\"] = \"object\",\r\n\t\t\t[\"properties\"] = properties\r\n\t\t};\r\n\r\n\t\tif ( required.Count > 0 )\r\n\t\t\tschema[\"required\"] = required;\r\n\r\n\t\treturn JsonSerializer.SerializeToElement( schema );\r\n\t}\r\n\r\n\tstatic JsonObject ForType( Type t )\r\n\t{\r\n\t\tt = Nullable.GetUnderlyingType( t ) ?? t;\r\n\r\n\t\tif ( t == typeof( string ) )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"string\" };\r\n\r\n\t\tif ( t == typeof( bool ) )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"boolean\" };\r\n\r\n\t\tif ( t == typeof( int ) || t == typeof( long ) || t == typeof( short ) || t == typeof( byte ) )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"integer\" };\r\n\r\n\t\tif ( t == typeof( float ) || t == typeof( double ) || t == typeof( decimal ) )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"number\" };\r\n\r\n\t\tif ( t.IsEnum )\r\n\t\t{\r\n\t\t\tvar values = new JsonArray();\r\n\t\t\tforeach ( var name in Enum.GetNames( t ) )\r\n\t\t\t\tvalues.Add( name );\r\n\r\n\t\t\treturn new JsonObject { [\"type\"] = \"string\", [\"enum\"] = values };\r\n\t\t}\r\n\r\n\t\tif ( t.IsArray )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"array\", [\"items\"] = ForType( t.GetElementType() ) };\r\n\r\n\t\tif ( t.IsGenericType && typeof( IEnumerable ).IsAssignableFrom( t ) )\r\n\t\t\treturn new JsonObject { [\"type\"] = \"array\", [\"items\"] = ForType( t.GetGenericArguments()[0] ) };\r\n\r\n\t\tif ( t == typeof( JsonElement ) )\r\n\t\t\treturn new JsonObject(); // accepts anything\r\n\r\n\t\t// fall back to a JSON-deserializable object\r\n\t\treturn new JsonObject { [\"type\"] = \"object\" };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Server/McpTypes.cs",
            "FileName": "McpTypes.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\n\r\nnamespace SboxMcp.Server;\r\n\r\n/// <summary>\r\n/// A tool as advertised to MCP clients via tools/list.\r\n/// </summary>\r\npublic record McpToolDescriptor( string Name, string Description, JsonElement InputSchema );\r\n\r\n/// <summary>\r\n/// Result payload shapes defined by the MCP specification.\r\n/// </summary>\r\npublic static class McpResults\r\n{\r\n\tpublic const string ServerName = \"sbox-mcp\";\r\n\tpublic const string ServerVersion = \"1.0.0\";\r\n\r\n\tpublic static object Initialize( string negotiatedVersion ) => new\r\n\t{\r\n\t\tprotocolVersion = negotiatedVersion,\r\n\t\tcapabilities = new { tools = new { listChanged = false } },\r\n\t\tserverInfo = new { name = ServerName, version = ServerVersion }\r\n\t};\r\n\r\n\tpublic static object ToolsList( IEnumerable<McpToolDescriptor> tools ) => new\r\n\t{\r\n\t\ttools = tools.ToArray()\r\n\t};\r\n\r\n\tpublic static object TextContent( string text, bool isError = false ) => new\r\n\t{\r\n\t\tcontent = new object[] { new { type = \"text\", text } },\r\n\t\tisError\r\n\t};\r\n\r\n\tpublic static object ImageContent( string base64Png, string text = null )\r\n\t{\r\n\t\tvar content = new List<object> { new { type = \"image\", data = base64Png, mimeType = \"image/png\" } };\r\n\t\tif ( !string.IsNullOrEmpty( text ) )\r\n\t\t\tcontent.Add( new { type = \"text\", text } );\r\n\r\n\t\treturn new { content = content.ToArray(), isError = false };\r\n\t}\r\n}\r\n\r\npublic static class McpVersion\r\n{\r\n\t/// <summary>\r\n\t/// Protocol revisions this server understands. 2025-06-18 only: older\r\n\t/// revisions REQUIRE JSON-RPC batch support, which this server does not\r\n\t/// implement, so advertising them would be a lie.\r\n\t/// </summary>\r\n\tpublic static readonly string[] Supported = { \"2025-06-18\" };\r\n\r\n\t/// <summary>Exact match wins; anything else gets our newest revision.</summary>\r\n\tpublic static string Negotiate( string clientRequested ) =>\r\n\t\tSupported.Contains( clientRequested ) ? clientRequested : Supported[0];\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Server/PathJail.cs",
            "FileName": "PathJail.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.IO;\r\n\r\nnamespace SboxMcp.Server;\r\n\r\n/// <summary>\r\n/// Confines file access to the project root. Every file-touching tool resolves\r\n/// paths through here.\r\n/// </summary>\r\npublic static class PathJail\r\n{\r\n\t/// <summary>\r\n\t/// Resolves <paramref name=\"path\"/> (relative to root, or absolute) and\r\n\t/// throws if it escapes <paramref name=\"root\"/>.\r\n\t/// </summary>\r\n\tpublic static string Resolve( string root, string path )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) )\r\n\t\t\tthrow new ArgumentException( \"Path must not be empty\" );\r\n\r\n\t\tvar rootFull = Path.GetFullPath( root )\r\n\t\t\t.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar );\r\n\r\n\t\tvar combined = Path.IsPathRooted( path ) ? path : Path.Combine( rootFull, path );\r\n\t\tvar full = Path.GetFullPath( combined );\r\n\r\n\t\tif ( !full.Equals( rootFull, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t&& !full.StartsWith( rootFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t{\r\n\t\t\tthrow new UnauthorizedAccessException( $\"Path '{path}' is outside the project and cannot be accessed\" );\r\n\t\t}\r\n\r\n\t\treturn full;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/ExtraTools.cs",
            "FileName": "ExtraTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Registry;\r\nusing static SboxMcp.Tools.ToolHelpers;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\n/// <summary>\r\n/// High-value concrete tools on top of the universal mechanisms: tags,\r\n/// bounds, orientation, bulk creation, component copy.\r\n/// </summary>\r\npublic static class ExtraTools\r\n{\r\n\t[McpTool( \"gameobject_add_tag\", \"Adds a tag to a GameObject (tags drive collision filtering, queries and gameplay logic).\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object AddTag( [Desc( \"GameObject id or unique name\" )] string gameObject, string tag )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: add tag {tag}\" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\t\tgo.Tags.Add( tag );\r\n\r\n\t\treturn new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_remove_tag\", \"Removes a tag from a GameObject.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object RemoveTag( [Desc( \"GameObject id or unique name\" )] string gameObject, string tag )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: remove tag {tag}\" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\t\tgo.Tags.Remove( tag );\r\n\r\n\t\treturn new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_get_bounds\", \"Gets a GameObject's world-space bounding box (renderers + children).\", ToolCategory.GameObject )]\r\n\tpublic static object GetBounds( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar go = FindGameObject( gameObject );\r\n\t\tvar b = go.GetBounds();\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tgameObject = go.Name,\r\n\t\t\tcenter = V( b.Center ),\r\n\t\t\tsize = V( b.Size ),\r\n\t\t\tmins = V( b.Mins ),\r\n\t\t\tmaxs = V( b.Maxs )\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_look_at\", \"Rotates a GameObject to face a target position or another GameObject.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object LookAt(\r\n\t\t[Desc( \"GameObject id or unique name to rotate\" )] string gameObject,\r\n\t\t[Desc( \"Target world position [x, y, z]; ignored when targetObject is set\" )] float[] position = null,\r\n\t\t[Desc( \"Target GameObject id/name to face\" )] string targetObject = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tvar target = targetObject is not null\r\n\t\t\t? FindGameObject( targetObject ).WorldPosition\r\n\t\t\t: position is not null ? ToVector3( position, \"position\" )\r\n\t\t\t: throw new ArgumentException( \"Pass either position or targetObject\" );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: look at\" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\t\tgo.WorldRotation = Rotation.LookAt( (target - go.WorldPosition).Normal );\r\n\r\n\t\treturn new { gameObject = go.Name, rotation = A( go.WorldRotation ) };\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_create_many\", \"Creates several GameObjects at once (e.g. a grid or row). Returns their ids.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object CreateMany(\r\n\t\t[Desc( \"Base name; each gets a numeric suffix\" )] string name,\r\n\t\t[Desc( \"How many to create\" )] int count,\r\n\t\t[Desc( \"Position of the first [x, y, z]\" )] float[] startPosition = null,\r\n\t\t[Desc( \"Offset added per object [x, y, z]\" )] float[] step = null,\r\n\t\t[Desc( \"Parent id; omit for scene root\" )] string parentId = null )\r\n\t{\r\n\t\tif ( count is < 1 or > 512 )\r\n\t\t\tthrow new ArgumentException( \"count must be 1..512\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\t\tvar parent = parentId is null ? null : FindGameObject( parentId );\r\n\t\tvar start = startPosition is null ? Vector3.Zero : ToVector3( startPosition, \"startPosition\" );\r\n\t\tvar delta = step is null ? new Vector3( 60, 0, 0 ) : ToVector3( step, \"step\" );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: create {count} objects\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar created = new object[count];\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar go = session.Scene.CreateObject();\r\n\t\t\tgo.Name = $\"{name} {i + 1}\";\r\n\t\t\tif ( parent is not null ) go.Parent = parent;\r\n\t\t\tgo.WorldPosition = start + delta * i;\r\n\t\t\tcreated[i] = new { id = go.Id, name = go.Name };\r\n\t\t}\r\n\r\n\t\treturn new { count, created };\r\n\t}\r\n\r\n\t[McpTool( \"component_copy\", \"Copies all property values from one component to another GameObject's component of the same type (e.g. clone a configured renderer's settings). Creates the component on the target if it doesn't have one yet.\", ToolCategory.Component, Writes = true )]\r\n\tpublic static object CopyComponent(\r\n\t\t[Desc( \"Source GameObject id or unique name\" )] string fromGameObject,\r\n\t\t[Desc( \"Target GameObject id or unique name\" )] string toGameObject,\r\n\t\t[Desc( \"Component type name\" )] string type )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar source = FindComponent( FindGameObject( fromGameObject ), type );\r\n\t\tvar toGo = FindGameObject( toGameObject );\r\n\r\n\t\tvar existing = toGo.Components.GetAll<Component>( FindMode.EverythingInSelf )\r\n\t\t\t.FirstOrDefault( c => c.GetType() == source.GetType() );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: copy {type}\" )\r\n\t\t\t.WithComponentCreations()\r\n\t\t\t.WithComponentChanges( existing is not null ? new[] { existing } : Array.Empty<Component>() )\r\n\t\t\t.Push();\r\n\r\n\t\t// create a matching component on the target if it has none yet\r\n\t\tvar target = existing ?? toGo.Components.Create( FindComponentType( type ) )\r\n\t\t\t?? throw new InvalidOperationException( $\"Could not create a {type} on '{toGameObject}'\" );\r\n\r\n\t\tif ( source.Serialize() is System.Text.Json.Nodes.JsonObject node )\r\n\t\t{\r\n\t\t\t// keep the target's own identity; copy only the values\r\n\t\t\tnode.Remove( \"__guid\" );\r\n\t\t\ttarget.DeserializeImmediately( node );\r\n\t\t}\r\n\r\n\t\treturn new { copied = type, from = fromGameObject, to = toGameObject, createdTarget = existing is null };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/GameObjectTools.cs",
            "FileName": "GameObjectTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Registry;\r\nusing static SboxMcp.Tools.ToolHelpers;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\npublic static class GameObjectTools\r\n{\r\n\t[McpTool( \"gameobject_create\", \"Creates a new GameObject in the active scene.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Create(\r\n\t\tstring name,\r\n\t\t[Desc( \"Id of the parent GameObject; omit for scene root\" )] string parentId = null,\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null,\r\n\t\t[Desc( \"Rotation [pitch, yaw, roll] in degrees\" )] float[] rotation = null,\r\n\t\t[Desc( \"Scale [x, y, z]\" )] float[] scale = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar parent = parentId is null ? null : FindGameObject( parentId );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: create {name}\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? \"GameObject\" : name;\r\n\r\n\t\tif ( parent is not null )\r\n\t\t\tgo.Parent = parent;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\tif ( rotation is not null )\r\n\t\t{\r\n\t\t\tif ( rotation.Length != 3 )\r\n\t\t\t\tthrow new ArgumentException( \"'rotation' must be [pitch, yaw, roll]\" );\r\n\r\n\t\t\tgo.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );\r\n\t\t}\r\n\r\n\t\tif ( scale is not null )\r\n\t\t\tgo.LocalScale = ToVector3( scale, \"scale\" );\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_spawn_model\", \"Spawns a prop in one step: creates a GameObject, adds a ModelRenderer with the given model, and optionally a matching ModelCollider so physics/traces hit it. The common 'place a model' operation (vs gameobject_create + component_add + component_set_property). Note: withCollider uses the model's own collision mesh - dev primitives like box.vmdl have none, so add a BoxCollider yourself for those.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnModel(\r\n\t\t[Desc( \"Model asset path, e.g. 'models/dev/box.vmdl'\" )] string model,\r\n\t\t[Desc( \"Object name; defaults to the model's file name\" )] string name = null,\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null,\r\n\t\t[Desc( \"Also add a ModelCollider (only solid if the model has a collision mesh)\" )] bool withCollider = false )\r\n\t{\r\n\t\tif ( AssetSystem.FindByPath( model ) is null )\r\n\t\t\tthrow new InvalidOperationException( $\"No model at '{model}' - use asset_search with assetType 'model'\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: spawn model\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? System.IO.Path.GetFileNameWithoutExtension( model ) : name;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\tvar loaded = Model.Load( model );\r\n\t\tgo.Components.Create<ModelRenderer>().Model = loaded;\r\n\r\n\t\tif ( withCollider )\r\n\t\t\tgo.Components.Create<ModelCollider>().Model = loaded;\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_spawn_light\", \"Spawns a light in one step: creates a GameObject with a PointLight, SpotLight, or DirectionalLight (optionally colored/aimed). Scenes need lighting - this is the one-call version.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnLight(\r\n\t\t[Desc( \"Light type: 'point', 'spot', or 'directional'\" )] string lightType = \"point\",\r\n\t\t[Desc( \"Object name; defaults to the light type\" )] string name = null,\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null,\r\n\t\t[Desc( \"Rotation [pitch, yaw, roll] - aims spot/directional lights\" )] float[] rotation = null,\r\n\t\t[Desc( \"Light color [r, g, b] (0-1); omit for white\" )] float[] color = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: spawn light\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\tif ( rotation is not null )\r\n\t\t{\r\n\t\t\tif ( rotation.Length != 3 )\r\n\t\t\t\tthrow new ArgumentException( \"'rotation' must be [pitch, yaw, roll]\" );\r\n\r\n\t\t\tgo.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );\r\n\t\t}\r\n\r\n\t\tLight light = (lightType ?? \"point\").ToLowerInvariant() switch\r\n\t\t{\r\n\t\t\t\"point\" or \"\" => go.Components.Create<PointLight>(),\r\n\t\t\t\"spot\" => go.Components.Create<SpotLight>(),\r\n\t\t\t\"directional\" or \"sun\" or \"dir\" => go.Components.Create<DirectionalLight>(),\r\n\t\t\t_ => throw new ArgumentException( \"lightType must be 'point', 'spot' or 'directional'\" )\r\n\t\t};\r\n\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? light.GetType().Name : name;\r\n\r\n\t\tif ( color is not null )\r\n\t\t{\r\n\t\t\tif ( color.Length is not (3 or 4) )\r\n\t\t\t\tthrow new ArgumentException( \"'color' must be [r, g, b] or [r, g, b, a]\" );\r\n\r\n\t\t\tlight.LightColor = new Color( color[0], color[1], color[2], color.Length > 3 ? color[3] : 1f );\r\n\t\t}\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_spawn_camera\", \"Spawns a camera in one step: creates a GameObject with a CameraComponent, optionally positioned/aimed with a field of view. Every scene needs a camera to render in play mode.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnCamera(\r\n\t\t[Desc( \"Object name\" )] string name = \"Camera\",\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null,\r\n\t\t[Desc( \"Rotation [pitch, yaw, roll] - where the camera looks\" )] float[] rotation = null,\r\n\t\t[Desc( \"Field of view in degrees (default 60)\" )] float fieldOfView = 60f )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: spawn camera\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? \"Camera\" : name;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\tif ( rotation is not null )\r\n\t\t{\r\n\t\t\tif ( rotation.Length != 3 )\r\n\t\t\t\tthrow new ArgumentException( \"'rotation' must be [pitch, yaw, roll]\" );\r\n\r\n\t\t\tgo.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );\r\n\t\t}\r\n\r\n\t\tgo.Components.Create<CameraComponent>().FieldOfView = fieldOfView;\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_delete\", \"Deletes a GameObject (and its children).\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Delete( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\t\tvar name = go.Name;\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: delete {name}\" )\r\n\t\t\t.WithGameObjectDestructions( new[] { go } ).Push();\r\n\r\n\t\tgo.Destroy();\r\n\t\treturn new { deleted = name };\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_rename\", \"Renames a GameObject.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Rename( [Desc( \"GameObject id or unique name\" )] string gameObject, string newName )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: rename to {newName}\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\r\n\t\tgo.Name = newName;\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_set_enabled\", \"Enables or disables a GameObject.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetEnabled( [Desc( \"GameObject id or unique name\" )] string gameObject, bool enabled )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: set enabled {enabled}\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\r\n\t\tgo.Enabled = enabled;\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_set_parent\", \"Reparents a GameObject (keeps world position).\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetParent(\r\n\t\t[Desc( \"GameObject id or unique name\" )] string gameObject,\r\n\t\t[Desc( \"New parent id; omit to move to scene root\" )] string parentId = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\t\tvar parent = parentId is null ? (GameObject)session.Scene : FindGameObject( parentId );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: reparent\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.All ).Push();\r\n\r\n\t\tgo.SetParent( parent, keepWorldPosition: true );\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_get_transform\", \"Gets a GameObject's world and local transform.\", ToolCategory.GameObject )]\r\n\tpublic static object GetTransform( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tid = go.Id,\r\n\t\t\tname = go.Name,\r\n\t\t\tworld = new { position = V( go.WorldPosition ), rotation = A( go.WorldRotation ), scale = V( go.WorldScale ) },\r\n\t\t\tlocal = new { position = V( go.LocalPosition ), rotation = A( go.LocalRotation ), scale = V( go.LocalScale ) }\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_set_transform\", \"Sets position/rotation/scale on a GameObject. Omitted parts stay unchanged.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetTransform(\r\n\t\t[Desc( \"GameObject id or unique name\" )] string gameObject,\r\n\t\t[Desc( \"Position [x, y, z]\" )] float[] position = null,\r\n\t\t[Desc( \"Rotation [pitch, yaw, roll] in degrees\" )] float[] rotation = null,\r\n\t\t[Desc( \"Scale [x, y, z]\" )] float[] scale = null,\r\n\t\t[Desc( \"Apply in world space instead of local space\" )] bool world = false )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: set transform\" )\r\n\t\t\t.WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();\r\n\r\n\t\tif ( position is not null )\r\n\t\t{\r\n\t\t\tvar v = ToVector3( position, \"position\" );\r\n\t\t\tif ( world ) go.WorldPosition = v; else go.LocalPosition = v;\r\n\t\t}\r\n\r\n\t\tif ( rotation is not null )\r\n\t\t{\r\n\t\t\tif ( rotation.Length != 3 )\r\n\t\t\t\tthrow new ArgumentException( \"'rotation' must be [pitch, yaw, roll]\" );\r\n\r\n\t\t\tvar r = Rotation.From( rotation[0], rotation[1], rotation[2] );\r\n\t\t\tif ( world ) go.WorldRotation = r; else go.LocalRotation = r;\r\n\t\t}\r\n\r\n\t\tif ( scale is not null )\r\n\t\t\tgo.LocalScale = ToVector3( scale, \"scale\" );\r\n\r\n\t\treturn GetTransform( go.Id.ToString() );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_duplicate\", \"Duplicates a GameObject next to the original.\", ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Duplicate( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tusing var undo = session.UndoScope( $\"MCP: duplicate {go.Name}\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar clone = go.Clone( go.WorldTransform, go.Parent, go.Enabled, $\"{go.Name} (copy)\" );\r\n\t\treturn Describe( clone );\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_find\", \"Searches GameObjects by name substring, component type, and/or tag.\", ToolCategory.GameObject )]\r\n\tpublic static object Find(\r\n\t\t[Desc( \"Name substring (case-insensitive); omit to match all\" )] string query = null,\r\n\t\t[Desc( \"Only objects having this component type\" )] string componentType = null,\r\n\t\t[Desc( \"Only objects carrying this tag\" )] string tag = null,\r\n\t\tint max = 50 )\r\n\t{\r\n\t\tvar scene = RequireScene();\r\n\r\n\t\tvar results = scene.GetAllObjects( false )\r\n\t\t\t.Where( o => o is not Scene )\r\n\t\t\t.Where( o => query is null || o.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Where( o => tag is null || o.Tags.Has( tag ) )\r\n\t\t\t.Where( o => componentType is null || o.Components.GetAll<Component>( FindMode.EverythingInSelf )\r\n\t\t\t\t.Any( c => string.Equals( c.GetType().Name, componentType, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t\t\t|| string.Equals( c.GetType().FullName, componentType, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\t.Take( max )\r\n\t\t\t.Select( Describe )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = results.Length, results };\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_get_details\", \"Gets a GameObject with all component properties as JSON.\", ToolCategory.GameObject )]\r\n\tpublic static object GetDetails( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tid = go.Id,\r\n\t\t\tname = go.Name,\r\n\t\t\tenabled = go.Enabled,\r\n\t\t\ttags = go.Tags.TryGetAll().ToArray(),\r\n\t\t\tworld = new { position = V( go.WorldPosition ), rotation = A( go.WorldRotation ), scale = V( go.WorldScale ) },\r\n\t\t\tparent = go.Parent is Scene ? null : (object)new { id = go.Parent?.Id, name = go.Parent?.Name },\r\n\t\t\tisPrefabInstance = go.IsPrefabInstance,\r\n\t\t\tprefabSource = go.PrefabInstanceSource,\r\n\t\t\tcomponents = go.Components.GetAll<Component>( FindMode.EverythingInSelf )\r\n\t\t\t\t.Select( c => new\r\n\t\t\t\t{\r\n\t\t\t\t\ttype = c.GetType().Name,\r\n\t\t\t\t\tenabled = c.Enabled,\r\n\t\t\t\t\tproperties = c.Serialize()\r\n\t\t\t\t} ).ToArray()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"gameobject_select\", \"Selects GameObjects in the editor (replaces current selection).\", ToolCategory.GameObject )]\r\n\tpublic static object Select( [Desc( \"GameObject ids or unique names\" )] string[] gameObjects )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar found = gameObjects.Select( FindGameObject ).ToList();\r\n\r\n\t\tsession.Selection.Clear();\r\n\t\tforeach ( var go in found )\r\n\t\t\tsession.Selection.Add( go );\r\n\r\n\t\treturn new { selected = found.Select( g => g.Name ).ToArray() };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/UI/McpDock.cs",
            "FileName": "McpDock.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing Editor;\r\nusing Sandbox;\r\nusing static Sandbox.Internal.GlobalToolsNamespace;\r\nusing SboxMcp.Integration;\r\n\r\nnamespace SboxMcp.UI;\r\n\r\n/// <summary>\r\n/// Top-level \"MCP\" menu in the editor menu bar (lands next to Help).\r\n/// </summary>\r\npublic static class McpMenu\r\n{\r\n\t[Menu( \"Editor\", \"MCP/Open Dashboard\", \"hub\" )]\r\n\tpublic static void OpenDashboard() => McpDock.Open();\r\n\r\n\t[Menu( \"Editor\", \"MCP/Start Server\", \"play_arrow\" )]\r\n\tpublic static void StartServer() => McpHost.Start();\r\n\r\n\t[Menu( \"Editor\", \"MCP/Stop Server\", \"stop\" )]\r\n\tpublic static void StopServer() => McpHost.Stop();\r\n}\r\n\r\n/// <summary>\r\n/// The MCP dashboard: header with live status, tab bar, and the four pages.\r\n/// Open it from the MCP menu in the menu bar.\r\n/// </summary>\r\npublic class McpDock : Widget\r\n{\r\n\tstatic McpDock _instance;\r\n\r\n\t/// <summary>The open dashboard instance, if any.</summary>\r\n\tpublic static McpDock Instance => _instance.IsValid() ? _instance : null;\r\n\r\n\treadonly HeaderBar _header;\r\n\treadonly TabButton[] _tabs;\r\n\treadonly Widget[] _pages;\r\n\treadonly OverviewPage _overview;\r\n\treadonly ActivityPage _activity;\r\n\treadonly ToolsPage _tools;\r\n\r\n\tint _active;\r\n\treadonly RealTimeSince _sinceCreated = 0;\r\n\r\n\t// Widget.MinimumWidth is a no-op for docks; Qt asks this instead\r\n\tprotected override Vector2 MinimumSizeHint() => new( 360, 220 );\r\n\r\n\tprotected override void OnResize()\r\n\t{\r\n\t\tbase.OnResize();\r\n\r\n\t\t// remember the user's size for future sessions; the settle delay keeps\r\n\t\t// the initial open/layout resizes from clobbering the saved value\r\n\t\tif ( _sinceCreated > 1f && Width > 100 && Height > 100 )\r\n\t\t\tMcpSettings.DockSize = Size;\r\n\t}\r\n\r\n\t/// <summary>Opens (or raises) the dashboard.</summary>\r\n\tpublic static McpDock Open()\r\n\t{\r\n\t\tvar dock = Instance;\r\n\r\n\t\tif ( dock is null )\r\n\t\t{\r\n\t\t\tdock = new McpDock( EditorWindow );\r\n\r\n\t\t\t// restore the last size the user resized it to\r\n\t\t\tdock.Size = McpSettings.DockSize;\r\n\r\n\t\t\t// dock to the right by default (s&box removed DockArea.Floating and the\r\n\t\t\t// widget overload now takes a title/icon); the user can drag it out to\r\n\t\t\t// float or re-dock it anywhere\r\n\t\t\tEditorWindow.DockManager.AddDock( \"MCP\", \"hub\", dock, DockArea.Right );\r\n\t\t\tdock.Size = McpSettings.DockSize;\r\n\t\t}\r\n\r\n\t\tEditorWindow.DockManager.RaiseDock( dock );\r\n\t\treturn dock;\r\n\t}\r\n\r\n\tpublic McpDock( Widget parent ) : base( parent )\r\n\t{\r\n\t\t_instance ??= this;\r\n\r\n\t\tName = \"McpDock\";\r\n\t\tWindowTitle = \"MCP\";\r\n\t\tSetWindowIcon( \"hub\" );\r\n\r\n\t\tLayout = Layout.Column();\r\n\r\n\t\t_header = Layout.Add( new HeaderBar( this ) );\r\n\r\n\t\tvar tabRow = Layout.AddRow();\r\n\t\ttabRow.Margin = new Sandbox.UI.Margin( 8, 4, 8, 0 );\r\n\t\ttabRow.Spacing = 2;\r\n\r\n\t\t_tabs = new[]\r\n\t\t{\r\n\t\t\tnew TabButton( \"Overview\", \"dashboard\", this ),\r\n\t\t\tnew TabButton( \"Activity\", \"bolt\", this ),\r\n\t\t\tnew TabButton( \"Tools\", \"construction\", this ),\r\n\t\t\tnew TabButton( \"Settings\", \"tune\", this )\r\n\t\t};\r\n\r\n\t\tfor ( var i = 0; i < _tabs.Length; i++ )\r\n\t\t{\r\n\t\t\tvar index = i;\r\n\t\t\t_tabs[i].Clicked = () => SetActive( index );\r\n\t\t\ttabRow.Add( _tabs[i] );\r\n\t\t}\r\n\r\n\t\ttabRow.AddStretchCell();\r\n\r\n\t\tvar content = Layout.Add( new Widget( this ), 1 );\r\n\t\tcontent.Layout = Layout.Column();\r\n\r\n\t\t_overview = new OverviewPage( content );\r\n\t\t_activity = new ActivityPage( content );\r\n\t\t_tools = new ToolsPage( content );\r\n\t\tvar settings = new SettingsPage( content );\r\n\r\n\t\t_pages = new Widget[] { _overview, _activity, _tools, settings };\r\n\r\n\t\tforeach ( var page in _pages )\r\n\t\t\tcontent.Layout.Add( page, 1 );\r\n\r\n\t\tSetActive( 0 );\r\n\t\t// no EditorEvent.Register(this) - QObject already registers every\r\n\t\t// widget; doing it again would run Tick twice per frame\r\n\t}\r\n\r\n\tpublic override void OnDestroyed()\r\n\t{\r\n\t\tbase.OnDestroyed();\r\n\t\tif ( _instance == this )\r\n\t\t\t_instance = null;\r\n\t}\r\n\r\n\tvoid SetActive( int index )\r\n\t{\r\n\t\t_active = index;\r\n\r\n\t\tfor ( var i = 0; i < _pages.Length; i++ )\r\n\t\t{\r\n\t\t\t_pages[i].Visible = i == index;\r\n\t\t\t_tabs[i].Active = i == index;\r\n\t\t\t_tabs[i].Update();\r\n\t\t}\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void Tick()\r\n\t{\r\n\t\tif ( !IsValid )\r\n\t\t\treturn;\r\n\r\n\t\tvar server = McpHost.Server;\r\n\t\tvar running = server?.IsRunning ?? false;\r\n\t\tvar sessions = server?.Sessions.Count ?? 0;\r\n\r\n\t\t_header.StatusColor = running ? Palette.Running : (McpHost.LastError is null ? Palette.Stopped : Palette.Error);\r\n\t\t_header.StatusText = !running\r\n\t\t\t? (McpHost.LastError is null ? \"stopped\" : \"error\")\r\n\t\t\t: sessions > 0 ? $\"running \u00b7 {sessions} client{(sessions == 1 ? \"\" : \"s\")}\" : \"running\";\r\n\t\t_header.Pulse = running ? (MathF.Sin( RealTime.Now * 3f ) + 1f) * 0.5f : 0f;\r\n\t\t_header.Update();\r\n\r\n\t\t// badge pending approvals on the Activity tab\r\n\t\tvar pending = PermissionGate.Pending.Count;\r\n\t\tif ( _tabs[1].Badge != pending )\r\n\t\t{\r\n\t\t\t_tabs[1].Badge = pending;\r\n\t\t\t_tabs[1].Update();\r\n\t\t}\r\n\r\n\t\t_overview.Tick();\r\n\t\t_activity.Tick();\r\n\t\t_tools.Tick();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/UI/McpStatusPill.cs",
            "FileName": "McpStatusPill.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Integration;\r\n\r\nnamespace SboxMcp.UI;\r\n\r\n/// <summary>\r\n/// Tiny MCP indicator in the editor's status bar: a status dot, the label and\r\n/// the connected-client count. Click to open the dashboard.\r\n/// </summary>\r\npublic class McpStatusPill : Widget\r\n{\r\n\tstring _signature;\r\n\r\n\tpublic McpStatusPill() : base( null )\r\n\t{\r\n\t\tFixedWidth = 70;\r\n\t\tFixedHeight = 20;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tToolTip = \"s&box MCP - click to open the dashboard\";\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\r\n\t\tvar server = McpHost.Server;\r\n\t\tvar running = server?.IsRunning ?? false;\r\n\t\tvar sessions = server?.Sessions.Count ?? 0;\r\n\t\tvar color = running ? Palette.Running : (McpHost.LastError is null ? Palette.Stopped : Palette.Error);\r\n\r\n\t\tif ( Paint.HasMouseOver )\r\n\t\t{\r\n\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.06f ) );\r\n\t\t\tPaint.DrawRect( LocalRect, 4 );\r\n\t\t}\r\n\r\n\t\tPaint.SetBrush( color );\r\n\t\tPaint.DrawCircle( new Vector2( LocalRect.Left + 9, LocalRect.Center.y ), 7 );\r\n\r\n\t\tPaint.SetPen( Palette.TextDim );\r\n\t\tPaint.SetDefaultFont( 7, 600 );\r\n\t\tPaint.DrawText( new Rect( LocalRect.Left + 17, LocalRect.Top, LocalRect.Width - 19, LocalRect.Height ),\r\n\t\t\trunning && sessions > 0 ? $\"MCP \u00b7 {sessions}\" : \"MCP\", TextFlag.LeftCenter );\r\n\t}\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseClick( e );\r\n\t\tMcpDock.Open();\r\n\t}\r\n\r\n\t// widgets are auto-registered for editor events; repaint when state changes\r\n\t[EditorEvent.Frame]\r\n\tpublic void Tick()\r\n\t{\r\n\t\tif ( !IsValid )\r\n\t\t\treturn;\r\n\r\n\t\tvar server = McpHost.Server;\r\n\t\tvar sig = $\"{server?.IsRunning}|{server?.Sessions.Count}|{McpHost.LastError is not null}\";\r\n\r\n\t\tif ( sig == _signature )\r\n\t\t\treturn;\r\n\r\n\t\t_signature = sig;\r\n\t\tUpdate();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Integration/McpSettings.cs",
            "FileName": "McpSettings.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing static Sandbox.Internal.GlobalToolsNamespace;\r\n\r\nnamespace SboxMcp.Integration;\r\n\r\n/// <summary>A user-imported tool: a public static method from another library.\r\n/// Signature (comma-joined parameter type names) distinguishes overloads;\r\n/// null when loaded from older persisted data.</summary>\r\npublic sealed record ImportedToolDef( string Assembly, string Type, string Method, string Signature = null );\r\n\r\n/// <summary>\r\n/// Persisted settings. EditorCookie is not thread-safe and must only be\r\n/// touched on the editor main thread, so values are cached in fields:\r\n/// getters are safe from any thread, setters are UI (main thread) only.\r\n/// </summary>\r\npublic static class McpSettings\r\n{\r\n\tpublic const int DefaultPort = 9090;\r\n\r\n\tstatic int _port = DefaultPort;\r\n\tstatic bool _portFromEnv;\r\n\tstatic bool _autoStart = true;\r\n\tstatic PermissionMode _mode = PermissionMode.FullAccess;\r\n\r\n\t/// <summary>True when the port came from the SBOX_MCP_PORT env var - used to\r\n\t/// isolate a second editor instance on its own port without persisting to (and\r\n\t/// disturbing) the shared EditorCookie every instance reads.</summary>\r\n\tpublic static bool IsPortFromEnv => _portFromEnv;\r\n\r\n\t/// <summary>Called once from the editor main thread before anything reads settings.</summary>\r\n\tinternal static void LoadFromCookies()\r\n\t{\r\n\t\t// env override wins so you can launch an isolated instance:\r\n\t\t// SBOX_MCP_PORT=9191 sbox-dev.exe ... -> binds 9191, cookie untouched\r\n\t\tvar env = Environment.GetEnvironmentVariable( \"SBOX_MCP_PORT\" );\r\n\t\tif ( int.TryParse( env, out var envPort ) && envPort is > 0 and < 65536 )\r\n\t\t{\r\n\t\t\t_port = envPort;\r\n\t\t\t_portFromEnv = true;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t_port = EditorCookie.Get( \"SboxMcp.Port\", DefaultPort );\r\n\t\t}\r\n\r\n\t\t_autoStart = EditorCookie.Get( \"SboxMcp.AutoStart\", true );\r\n\t\t_mode = EditorCookie.Get( \"SboxMcp.PermissionMode\", PermissionMode.FullAccess );\r\n\t\tLoadExtras();\r\n\t}\r\n\r\n\tpublic static int Port\r\n\t{\r\n\t\tget => _port;\r\n\t\t// don't clobber the shared cookie when an env override is driving the port\r\n\t\tset { _port = value; if ( !_portFromEnv ) EditorCookie.Set( \"SboxMcp.Port\", value ); }\r\n\t}\r\n\r\n\tpublic static bool AutoStart\r\n\t{\r\n\t\tget => _autoStart;\r\n\t\tset { _autoStart = value; EditorCookie.Set( \"SboxMcp.AutoStart\", value ); }\r\n\t}\r\n\r\n\tpublic static PermissionMode Mode\r\n\t{\r\n\t\tget => _mode;\r\n\t\tset { _mode = value; EditorCookie.Set( \"SboxMcp.PermissionMode\", value ); }\r\n\t}\r\n\r\n\t// ---- dashboard window size (persisted) ---------------------------------\r\n\r\n\tstatic Vector2 _dockSize = new( 420, 560 );\r\n\r\n\tpublic static Vector2 DockSize\r\n\t{\r\n\t\tget => _dockSize;\r\n\t\tset\r\n\t\t{\r\n\t\t\t_dockSize = value;\r\n\t\t\tEditorCookie.Set( \"SboxMcp.DockSize\", $\"{(int)value.x}x{(int)value.y}\" );\r\n\t\t}\r\n\t}\r\n\r\n\t// ---- per-tool enable/disable overrides (persisted) ---------------------\r\n\r\n\t// reference-swapped on change so worker threads can read without locks;\r\n\t// absence of a key means \"use the tool's default\"\r\n\tstatic Dictionary<string, bool> _toolDisabledOverrides = new();\r\n\r\n\t/// <summary>The user's explicit choice for a tool, or null = tool default.</summary>\r\n\tpublic static bool? GetToolDisabledOverride( string toolName ) =>\r\n\t\t_toolDisabledOverrides.TryGetValue( toolName, out var disabled ) ? disabled : null;\r\n\r\n\t/// <summary>UI/main thread only (writes a cookie).</summary>\r\n\tpublic static void SetToolDisabled( string toolName, bool disabled )\r\n\t{\r\n\t\tvar next = new Dictionary<string, bool>( _toolDisabledOverrides ) { [toolName] = disabled };\r\n\t\t_toolDisabledOverrides = next;\r\n\t\tEditorCookie.Set( \"SboxMcp.ToolOverrides\",\r\n\t\t\tstring.Join( \";\", next.Select( kv => $\"{kv.Key}={(kv.Value ? 1 : 0)}\" ) ) );\r\n\t}\r\n\r\n\t// ---- imported tools (persisted) ----------------------------------------\r\n\r\n\tstatic List<ImportedToolDef> _importedTools = new();\r\n\r\n\tpublic static IReadOnlyList<ImportedToolDef> ImportedTools => _importedTools;\r\n\r\n\t/// <summary>UI/main thread only (writes a cookie).</summary>\r\n\tpublic static void AddImportedTool( ImportedToolDef def )\r\n\t{\r\n\t\tif ( _importedTools.Contains( def ) )\r\n\t\t\treturn;\r\n\r\n\t\t_importedTools = new List<ImportedToolDef>( _importedTools ) { def };\r\n\t\tSaveImports();\r\n\t}\r\n\r\n\t/// <summary>UI/main thread only (writes a cookie).</summary>\r\n\tpublic static void RemoveImportedTool( ImportedToolDef def )\r\n\t{\r\n\t\t_importedTools = _importedTools.Where( d => d != def ).ToList();\r\n\t\tSaveImports();\r\n\t}\r\n\r\n\tstatic void SaveImports() =>\r\n\t\tEditorCookie.Set( \"SboxMcp.ImportedTools\", JsonSerializer.Serialize( _importedTools ) );\r\n\r\n\tstatic void LoadExtras()\r\n\t{\r\n\t\tvar size = EditorCookie.Get( \"SboxMcp.DockSize\", \"\" );\r\n\t\tvar sizeParts = size.Split( 'x' );\r\n\t\tif ( sizeParts.Length == 2 && int.TryParse( sizeParts[0], out var w ) && int.TryParse( sizeParts[1], out var h ) )\r\n\t\t\t_dockSize = new Vector2( Math.Max( w, 360 ), Math.Max( h, 220 ) );\r\n\r\n\t\tvar overrides = EditorCookie.Get( \"SboxMcp.ToolOverrides\", \"\" );\r\n\t\t_toolDisabledOverrides = overrides\r\n\t\t\t.Split( ';', StringSplitOptions.RemoveEmptyEntries )\r\n\t\t\t.Select( pair => pair.Split( '=' ) )\r\n\t\t\t.Where( parts => parts.Length == 2 )\r\n\t\t\t.ToDictionary( parts => parts[0], parts => parts[1] == \"1\" );\r\n\r\n\t\tvar imports = EditorCookie.Get( \"SboxMcp.ImportedTools\", \"\" );\r\n\t\ttry\r\n\t\t{\r\n\t\t\t_importedTools = string.IsNullOrWhiteSpace( imports )\r\n\t\t\t\t? new List<ImportedToolDef>()\r\n\t\t\t\t: JsonSerializer.Deserialize<List<ImportedToolDef>>( imports ) ?? new List<ImportedToolDef>();\r\n\t\t}\r\n\t\tcatch ( JsonException )\r\n\t\t{\r\n\t\t\t_importedTools = new List<ImportedToolDef>();\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Integration/ToolImporter.cs",
            "FileName": "ToolImporter.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing SboxMcp.Registry;\r\n\r\nnamespace SboxMcp.Integration;\r\n\r\n/// <summary>\r\n/// Lets the user expose public static methods from other installed libraries\r\n/// as MCP tools. Imports are persisted (per editor, via cookies) and re-bound\r\n/// every session; methods whose library is gone simply don't register until\r\n/// it returns.\r\n/// </summary>\r\npublic static class ToolImporter\r\n{\r\n\tstatic readonly Type[] BindableParams =\r\n\t{\r\n\t\ttypeof( string ), typeof( int ), typeof( long ), typeof( float ), typeof( double ),\r\n\t\ttypeof( bool ), typeof( string[] ), typeof( int[] ), typeof( float[] )\r\n\t};\r\n\r\n\t// system/engine assemblies are never offered as import sources\r\n\tstatic readonly string[] ExcludedPrefixes =\r\n\t{\r\n\t\t\"System\", \"Microsoft\", \"netstandard\", \"mscorlib\", \"Sandbox\", \"Facepunch\",\r\n\t\t\"NLog\", \"Sentry\", \"Refit\", \"protobuf\", \"Mono\", \"MonoMod\", \"Skia\", \"Topten\",\r\n\t\t\"Humanizer\", \"Azure\", \"LiteDB\", \"Fleck\", \"Zio\", \"ExCSS\", \"xunit\", \"JetBrains\"\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// True for assemblies compiled from installed s&box libraries (named\r\n\t/// \"package.{org}.{ident}[.editor]\"), excluding the open project's own code.\r\n\t/// </summary>\r\n\tpublic static bool IsLibraryAssembly( Assembly assembly )\r\n\t{\r\n\t\tvar name = assembly.GetName().Name ?? \"\";\r\n\t\tif ( !name.StartsWith( \"package.\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\treturn false;\r\n\r\n\t\tvar config = Sandbox.Project.Current?.Config;\r\n\t\tif ( config is null )\r\n\t\t\treturn true;\r\n\r\n\t\treturn !name.StartsWith( $\"package.{config.Org}.{config.Ident}\", StringComparison.OrdinalIgnoreCase );\r\n\t}\r\n\r\n\tpublic static string FriendlyName( Assembly assembly )\r\n\t{\r\n\t\tvar name = assembly.GetName().Name ?? \"?\";\r\n\t\treturn name.StartsWith( \"package.\", StringComparison.OrdinalIgnoreCase ) ? name[8..] : name;\r\n\t}\r\n\r\n\t/// <summary>Loaded assemblies that look like user libraries with importable methods.</summary>\r\n\tpublic static IEnumerable<Assembly> CandidateAssemblies()\r\n\t{\r\n\t\tvar own = typeof( ToolImporter ).Assembly;\r\n\r\n\t\treturn AppDomain.CurrentDomain.GetAssemblies()\r\n\t\t\t.Where( a => !a.IsDynamic && a != own )\r\n\t\t\t.Where( a =>\r\n\t\t\t{\r\n\t\t\t\tvar name = a.GetName().Name ?? \"\";\r\n\t\t\t\treturn name.Length > 0 && !ExcludedPrefixes.Any( p => name.StartsWith( p, StringComparison.OrdinalIgnoreCase ) );\r\n\t\t\t} )\r\n\t\t\t.Where( a => CandidateMethods( a ).Any() )\r\n\t\t\t.OrderBy( a => a.GetName().Name );\r\n\t}\r\n\r\n\t/// <summary>Public static methods with simple, schema-expressible parameters.</summary>\r\n\tpublic static IEnumerable<MethodInfo> CandidateMethods( Assembly assembly )\r\n\t{\r\n\t\tType[] types;\r\n\t\ttry { types = assembly.GetExportedTypes(); }\r\n\t\tcatch { yield break; }\r\n\r\n\t\tforeach ( var type in types.Where( t => t.IsClass && !t.IsGenericTypeDefinition ) )\r\n\t\t{\r\n\t\t\tforeach ( var method in type.GetMethods( BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly ) )\r\n\t\t\t{\r\n\t\t\t\tif ( method.IsSpecialName || method.IsGenericMethodDefinition )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tif ( method.GetParameters().All( p => BindableParams.Contains( p.ParameterType ) || p.ParameterType.IsEnum ) )\r\n\t\t\t\t\tyield return method;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static string ToolNameFor( ImportedToolDef def )\r\n\t{\r\n\t\tvar typeName = def.Type.Split( '.' ).Last();\r\n\t\t// include a short signature suffix so overloads and same-named types\r\n\t\t// don't collide on one tool name\r\n\t\tvar suffix = string.IsNullOrEmpty( def.Signature ) ? \"\" : \"_\" + Math.Abs( def.Signature.GetHashCode() % 10000 );\r\n\t\treturn Sanitize( $\"lib_{typeName}_{def.Method}{suffix}\" );\r\n\t}\r\n\r\n\tstatic string Sanitize( string name ) =>\r\n\t\tnew( name.Select( c => char.IsLetterOrDigit( c ) ? char.ToLowerInvariant( c ) : '_' ).ToArray() );\r\n\r\n\tstatic string SignatureOf( MethodInfo method ) =>\r\n\t\tstring.Join( \",\", method.GetParameters().Select( p => p.ParameterType.Name ) );\r\n\r\n\tpublic static bool IsImported( MethodInfo method ) =>\r\n\t\tMcpSettings.ImportedTools.Contains( DefFor( method ) );\r\n\r\n\tpublic static ImportedToolDef DefFor( MethodInfo method ) =>\r\n\t\tnew( method.DeclaringType?.Assembly.GetName().Name, method.DeclaringType?.FullName, method.Name, SignatureOf( method ) );\r\n\r\n\t/// <summary>Imports a method now and persists the choice.</summary>\r\n\tpublic static void Import( MethodInfo method )\r\n\t{\r\n\t\tvar def = DefFor( method );\r\n\t\tMcpSettings.AddImportedTool( def );\r\n\t\tRegister( McpHost.Registry, def, method );\r\n\t}\r\n\r\n\t/// <summary>Removes an import now and persists the choice.</summary>\r\n\tpublic static void Unimport( MethodInfo method )\r\n\t{\r\n\t\tvar def = DefFor( method );\r\n\t\tMcpSettings.RemoveImportedTool( def );\r\n\t\tMcpHost.Registry?.Remove( ToolNameFor( def ) );\r\n\t}\r\n\r\n\t/// <summary>Re-binds every persisted import that still resolves.</summary>\r\n\tpublic static void RegisterSaved( ToolRegistry registry )\r\n\t{\r\n\t\tforeach ( var def in McpSettings.ImportedTools )\r\n\t\t{\r\n\t\t\tvar method = Resolve( def );\r\n\t\t\tif ( method is not null )\r\n\t\t\t\tRegister( registry, def, method );\r\n\t\t\telse\r\n\t\t\t\tMcpHost.Log.Warning( $\"Imported tool {def.Type}.{def.Method} not found ({def.Assembly} missing?) - it will return when the library does\" );\r\n\t\t}\r\n\t}\r\n\r\n\tstatic MethodInfo Resolve( ImportedToolDef def )\r\n\t{\r\n\t\tvar assembly = AppDomain.CurrentDomain.GetAssemblies()\r\n\t\t\t.LastOrDefault( a => a.GetName().Name == def.Assembly );\r\n\r\n\t\tvar type = assembly?.GetType( def.Type );\r\n\t\tvar overloads = type?.GetMethods( BindingFlags.Public | BindingFlags.Static )\r\n\t\t\t.Where( m => m.Name == def.Method && !m.IsGenericMethodDefinition )\r\n\t\t\t.ToArray() ?? Array.Empty<MethodInfo>();\r\n\r\n\t\t// match the exact overload the user picked; older data (null signature)\r\n\t\t// falls back to the first, preserving prior behavior\r\n\t\treturn def.Signature is null\r\n\t\t\t? overloads.FirstOrDefault()\r\n\t\t\t: overloads.FirstOrDefault( m => SignatureOf( m ) == def.Signature ) ?? overloads.FirstOrDefault();\r\n\t}\r\n\r\n\tstatic void Register( ToolRegistry registry, ImportedToolDef def, MethodInfo method )\r\n\t{\r\n\t\tif ( registry is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar parameters = string.Join( \", \", method.GetParameters().Select( p => p.Name ) );\r\n\t\tvar registered = registry.AddImported(\r\n\t\t\tToolNameFor( def ),\r\n\t\t\t$\"Imported from the '{def.Assembly}' library: {def.Type.Split( '.' ).Last()}.{def.Method}({parameters})\",\r\n\t\t\tToolCategory.Imported,\r\n\t\t\tmethod );\r\n\r\n\t\tif ( registered is null )\r\n\t\t\tMcpHost.Log.Warning( $\"Could not import {def.Type}.{def.Method} - a tool named '{ToolNameFor( def )}' already exists\" );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/CodeTools.cs",
            "FileName": "CodeTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Reflection;\r\nusing System.Text.Json;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\nusing SboxMcp.Integration;\r\nusing SboxMcp.Registry;\r\nusing static SboxMcp.Tools.AssetTools;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\npublic static class CodeTools\r\n{\r\n\tstatic readonly string[] SkippedDirs = { \"\\\\obj\\\\\", \"\\\\bin\\\\\", \"/obj/\", \"/bin/\" };\r\n\tstatic readonly string[] SourceExtensions = { \".cs\", \".razor\", \".scss\", \".shader\", \".hlsl\" };\r\n\r\n\t/// <summary>Skip build output and any dot-directory (.git, .sbox, .removed-libraries...).</summary>\r\n\tstatic bool IsSkipped( string fullPath )\r\n\t{\r\n\t\tif ( SkippedDirs.Any( s => fullPath.Contains( s, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\treturn true;\r\n\r\n\t\t// any path segment starting with '.'\r\n\t\treturn fullPath.Replace( '\\\\', '/' ).Split( '/' ).Any( seg => seg.StartsWith( '.' ) && seg.Length > 1 );\r\n\t}\r\n\r\n\t[McpTool( \"code_list_files\", \"Lists source files in the project: C# (.cs), UI (.razor/.scss) and shaders. Saving a file hot-reloads automatically.\", ToolCategory.Code )]\r\n\tpublic static object ListFiles(\r\n\t\t[Desc( \"Subdirectory filter relative to project root, e.g. 'Code/Player'\" )] string subdir = null,\r\n\t\t[Desc( \"Include files from installed Libraries\" )] bool includeLibraries = false )\r\n\t{\r\n\t\tvar root = ProjectRoot;\r\n\t\tvar searchRoot = subdir is null ? root : ResolveInProject( subdir );\r\n\r\n\t\tif ( !Directory.Exists( searchRoot ) )\r\n\t\t\tthrow new InvalidOperationException( $\"No directory '{subdir}' in the project\" );\r\n\r\n\t\tvar files = Directory.EnumerateFiles( searchRoot, \"*.*\", SearchOption.AllDirectories )\r\n\t\t\t.Where( f => SourceExtensions.Contains( Path.GetExtension( f ), StringComparer.OrdinalIgnoreCase ) )\r\n\t\t\t.Where( f => !IsSkipped( f ) )\r\n\t\t\t.Where( f => includeLibraries || !f.Contains( Path.DirectorySeparatorChar + \"Libraries\" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( f => Path.GetRelativePath( root, f ).Replace( '\\\\', '/' ) )\r\n\t\t\t.OrderBy( f => f )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = files.Length, files };\r\n\t}\r\n\r\n\t[McpTool( \"code_search\", \"Searches project source files (C#/Razor/SCSS/shaders) for a substring or regex - find where a symbol is used, a class is defined, etc. Returns file:line matches.\", ToolCategory.Code )]\r\n\tpublic static object Search(\r\n\t\t[Desc( \"Text or regex to find\" )] string pattern,\r\n\t\t[Desc( \"Treat pattern as a regular expression\" )] bool regex = false,\r\n\t\t[Desc( \"Case-sensitive match\" )] bool caseSensitive = false,\r\n\t\t[Desc( \"Limit to a subdirectory relative to project root\" )] string subdir = null,\r\n\t\t[Desc( \"Also search installed library source under Libraries/\" )] bool includeLibraries = false,\r\n\t\tint max = 100 )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( pattern ) )\r\n\t\t\tthrow new ArgumentException( \"pattern must not be empty\" );\r\n\r\n\t\tvar root = ProjectRoot;\r\n\t\tvar searchRoot = subdir is null ? root : ResolveInProject( subdir );\r\n\t\tif ( !Directory.Exists( searchRoot ) )\r\n\t\t\tthrow new InvalidOperationException( $\"No directory '{subdir}' - use code_list_files to see the layout\" );\r\n\r\n\t\tvar comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;\r\n\t\tSystem.Text.RegularExpressions.Regex rx = null;\r\n\t\tif ( regex )\r\n\t\t\trx = new System.Text.RegularExpressions.Regex( pattern,\r\n\t\t\t\tcaseSensitive ? System.Text.RegularExpressions.RegexOptions.None : System.Text.RegularExpressions.RegexOptions.IgnoreCase );\r\n\r\n\t\tvar matches = new List<object>();\r\n\r\n\t\tforeach ( var file in Directory.EnumerateFiles( searchRoot, \"*.*\", SearchOption.AllDirectories ) )\r\n\t\t{\r\n\t\t\tif ( !SourceExtensions.Contains( Path.GetExtension( file ), StringComparer.OrdinalIgnoreCase ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tif ( IsSkipped( file ) )\r\n\t\t\t\tcontinue;\r\n\t\t\tif ( !includeLibraries && file.Contains( Path.DirectorySeparatorChar + \"Libraries\" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar rel = Path.GetRelativePath( root, file ).Replace( '\\\\', '/' );\r\n\t\t\tvar lines = File.ReadAllLines( file );\r\n\t\t\tfor ( var i = 0; i < lines.Length; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar hit = rx is not null ? rx.IsMatch( lines[i] ) : lines[i].Contains( pattern, comparison );\r\n\t\t\t\tif ( !hit ) continue;\r\n\r\n\t\t\t\tmatches.Add( new { file = rel, line = i + 1, text = lines[i].Trim() } );\r\n\t\t\t\tif ( matches.Count >= max ) break;\r\n\t\t\t}\r\n\t\t\tif ( matches.Count >= max ) break;\r\n\t\t}\r\n\r\n\t\treturn new { count = matches.Count, truncated = matches.Count >= max, matches };\r\n\t}\r\n\r\n\t[McpTool( \"code_read_file\", \"Reads a project source file.\", ToolCategory.Code )]\r\n\tpublic static object ReadFile( [Desc( \"Path relative to project root, e.g. 'Code/Player.cs'\" )] string path )\r\n\t{\r\n\t\tvar absolute = ResolveInProject( path );\r\n\r\n\t\tif ( !File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\"No file at '{path}' - use code_list_files\" );\r\n\r\n\t\treturn new { path, content = File.ReadAllText( absolute ) };\r\n\t}\r\n\r\n\t[McpTool( \"code_write_file\", \"Writes a project source file (creating it if missing). The editor hot-reloads changed code automatically; check editor_get_logs / code_get_compile_errors afterwards.\", ToolCategory.Code, Writes = true )]\r\n\tpublic static object WriteFile(\r\n\t\t[Desc( \"Path relative to project root, e.g. 'Code/Player.cs'\" )] string path,\r\n\t\t[Desc( \"Full new file content\" )] string content )\r\n\t{\r\n\t\tvar absolute = ResolveInProject( path );\r\n\r\n\t\tDirectory.CreateDirectory( Path.GetDirectoryName( absolute ) );\r\n\t\tFile.WriteAllText( absolute, content );\r\n\r\n\t\treturn new { written = path, note = \"hot-reload triggers automatically; verify with code_get_compile_errors\" };\r\n\t}\r\n\r\n\t[McpTool( \"code_edit_file\", \"Replaces an exact text snippet in a project source file - a targeted edit, versus code_write_file which rewrites the whole file. The old text must appear EXACTLY ONCE (include surrounding context to make it unique). The editor hot-reloads afterward.\", ToolCategory.Code, Writes = true )]\r\n\tpublic static object EditFile(\r\n\t\t[Desc( \"Path relative to project root, e.g. 'Code/Player.cs'\" )] string path,\r\n\t\t[Desc( \"Exact existing text to replace (must be unique in the file, whitespace included)\" )] string oldText,\r\n\t\t[Desc( \"Replacement text\" )] string newText )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( oldText ) )\r\n\t\t\tthrow new ArgumentException( \"oldText must not be empty - use code_write_file to create/overwrite a file\" );\r\n\r\n\t\tvar absolute = ResolveInProject( path );\r\n\t\tif ( !File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\"No file at '{path}' - use code_list_files\" );\r\n\r\n\t\tvar content = File.ReadAllText( absolute );\r\n\r\n\t\tvar first = content.IndexOf( oldText, StringComparison.Ordinal );\r\n\t\tif ( first < 0 )\r\n\t\t\tthrow new InvalidOperationException( $\"The old text was not found in '{path}' - read it with code_read_file and match exactly (whitespace included)\" );\r\n\t\tif ( content.IndexOf( oldText, first + 1, StringComparison.Ordinal ) >= 0 )\r\n\t\t\tthrow new InvalidOperationException( $\"The old text appears more than once in '{path}' - include more surrounding context to make it unique\" );\r\n\r\n\t\tFile.WriteAllText( absolute, content.Remove( first, oldText.Length ).Insert( first, newText ) );\r\n\r\n\t\treturn new { edited = path, note = \"hot-reload triggers automatically; verify with code_get_compile_errors\" };\r\n\t}\r\n\r\n\t[McpTool( \"code_create_component\", \"Scaffolds a new Component C# file (a script you can add to GameObjects) with the standard boilerplate and any [Property] fields. The editor hot-reloads it, then add it with component_add.\", ToolCategory.Code, Writes = true )]\r\n\tpublic static object CreateComponent(\r\n\t\t[Desc( \"Component class name, e.g. 'PlayerMovement'\" )] string className,\r\n\t\t[Desc( \"Namespace; omit for the project default\" )] string @namespace = null,\r\n\t\t[Desc( \"Property fields as 'Type Name' pairs, e.g. ['float Speed', 'GameObject Target']\" )] string[] properties = null,\r\n\t\t[Desc( \"Add an OnUpdate() method body\" )] bool withUpdate = true )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( className ) || !char.IsLetter( className[0] ) )\r\n\t\t\tthrow new ArgumentException( \"className must start with a letter\" );\r\n\r\n\t\tvar ns = @namespace ?? DefaultNamespace();\r\n\t\tvar sb = new System.Text.StringBuilder();\r\n\t\tsb.AppendLine( \"using Sandbox;\" ).AppendLine();\r\n\t\tsb.AppendLine( $\"namespace {ns};\" ).AppendLine();\r\n\t\tsb.AppendLine( $\"public sealed class {className} : Component\" );\r\n\t\tsb.AppendLine( \"{\" );\r\n\r\n\t\tforeach ( var p in properties ?? Array.Empty<string>() )\r\n\t\t{\r\n\t\t\tvar parts = p.Split( ' ', StringSplitOptions.RemoveEmptyEntries );\r\n\t\t\tif ( parts.Length == 2 )\r\n\t\t\t\tsb.AppendLine( $\"\\t[Property] public {parts[0]} {parts[1]} {{ get; set; }}\" ).AppendLine();\r\n\t\t}\r\n\r\n\t\tif ( withUpdate )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\tprotected override void OnUpdate()\" );\r\n\t\t\tsb.AppendLine( \"\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t// runs every frame while the component is enabled\" );\r\n\t\t\tsb.AppendLine( \"\\t}\" );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine( \"}\" );\r\n\r\n\t\tvar path = $\"Code/{className}.cs\";\r\n\t\tvar absolute = ResolveInProject( path );\r\n\t\tif ( File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\"'{path}' already exists - edit it with code_write_file\" );\r\n\r\n\t\tDirectory.CreateDirectory( Path.GetDirectoryName( absolute ) );\r\n\t\tFile.WriteAllText( absolute, sb.ToString() );\r\n\r\n\t\treturn new { created = path, className, note = $\"hot-reloading; then component_add(go, \\\"{className}\\\")\" };\r\n\t}\r\n\r\n\tstatic string DefaultNamespace()\r\n\t{\r\n\t\t// RootNamespace lives in the .sbproj; read it from there rather than\r\n\t\t// guessing the config property name\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar sbproj = Directory.GetFiles( ProjectRoot, \"*.sbproj\" ).FirstOrDefault();\r\n\t\t\tif ( sbproj is not null\r\n\t\t\t\t&& System.Text.Json.Nodes.JsonNode.Parse( File.ReadAllText( sbproj ) ) is System.Text.Json.Nodes.JsonObject json )\r\n\t\t\t{\r\n\t\t\t\t// RootNamespace lives at Metadata.Compiler.RootNamespace; fall back to root\r\n\t\t\t\tvar ns = json[\"Metadata\"]?[\"Compiler\"]?[\"RootNamespace\"]?.GetValue<string>()\r\n\t\t\t\t\t?? json[\"RootNamespace\"]?.GetValue<string>();\r\n\t\t\t\tif ( !string.IsNullOrWhiteSpace( ns ) )\r\n\t\t\t\t\treturn ns;\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch { /* fall through to default */ }\r\n\r\n\t\treturn \"Sandbox\";\r\n\t}\r\n\r\n\t[McpTool( \"code_run_static_method\", \"Invokes a public static method from project code, optionally WITH arguments - write a method with code_write_file/code_edit_file, wait for hot-reload, then call it to test or inspect game state. If the method returns a Task/Task<T> it is AWAITED and its result returned (not the Task object). Returns the result's ToString.\", ToolCategory.Code, Writes = true )]\r\n\tpublic static async Task<object> RunStaticMethod(\r\n\t\t[Desc( \"Type name, e.g. 'MyGame.DebugHelpers'\" )] string typeName,\r\n\t\t[Desc( \"Public static method name\" )] string methodName,\r\n\t\t[Desc( \"Positional argument values as a JSON array, e.g. [5, \\\"hi\\\", true]; omit for a no-arg method\" )] JsonElement args = default )\r\n\t{\r\n\t\tvar typeDesc = Sandbox.Internal.GlobalToolsNamespace.EditorTypeLibrary.GetType( typeName )\r\n\t\t\t?? throw new InvalidOperationException( $\"No type '{typeName}' - is it compiled? Check code_get_compile_errors\" );\r\n\r\n\t\tvar clrType = typeDesc.TargetType\r\n\t\t\t?? throw new InvalidOperationException( $\"'{typeName}' has no usable CLR type\" );\r\n\r\n\t\t// tolerate args passed as a real array OR a stringified array (MCP clients\r\n\t\t// often stringify) - was the cause of spurious \"taking 0 arguments\" errors\r\n\t\tvar argList = ToolHelpers.NormalizeArgs( args );\r\n\t\tvar argCount = argList.Length;\r\n\r\n\t\tvar method = clrType.GetMethods( BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy )\r\n\t\t\t.FirstOrDefault( m => m.Name == methodName && !m.IsGenericMethodDefinition && m.GetParameters().Length == argCount )\r\n\t\t\t?? throw new InvalidOperationException(\r\n\t\t\t\t$\"'{typeName}' has no public static method '{methodName}' taking {argCount} argument(s) - use api_get_type to see its methods\" );\r\n\r\n\t\t// marshal each JSON arg to the parameter's type (BindOptions resolves\r\n\t\t// engine value types like Vector3/Rotation) - a hard error if it can't\r\n\t\tvar parameters = method.GetParameters();\r\n\t\tvar bound = new object[parameters.Length];\r\n\t\tfor ( var i = 0; i < parameters.Length; i++ )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tbound[i] = argList[i].Deserialize( parameters[i].ParameterType, ToolRegistry.BindOptions );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception e )\r\n\t\t\t{\r\n\t\t\t\tthrow new InvalidOperationException(\r\n\t\t\t\t\t$\"Argument {i} ('{parameters[i].Name}') could not be read as {parameters[i].ParameterType.Name}: {e.Message}\" );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tobject result;\r\n\t\ttry\r\n\t\t{\r\n\t\t\tresult = method.Invoke( null, bound );\r\n\t\t}\r\n\t\tcatch ( TargetInvocationException e ) when ( e.InnerException is not null )\r\n\t\t{\r\n\t\t\tthrow e.InnerException;\r\n\t\t}\r\n\r\n\t\t// await a Task/Task<T> so a diagnostic method can be async without the\r\n\t\t// caller getting back \"System.Threading.Tasks.Task`1[System.String]\"\r\n\t\tvar awaited = await ToolHelpers.AwaitIfTask( result );\r\n\r\n\t\treturn new { invoked = $\"{typeName}.{methodName}\", args = argCount, result = awaited?.ToString() ?? \"null\" };\r\n\t}\r\n\r\n\t[McpTool( \"build_info\", \"Reports the identity of the currently-loaded build: a server buildId plus, for a given type, the MVID/timestamp of the assembly that type lives in. Call it after a compile to confirm your NEW code is actually live (the MVID changes on every recompile) - replaces planting a throwaway Log.Info canary to check for stale assemblies.\", ToolCategory.Code )]\r\n\tpublic static object BuildInfo(\r\n\t\t[Desc( \"Optional type to inspect, e.g. 'MyGame.DebugHelpers' - reports the assembly that holds it\" )] string typeName = null )\r\n\t{\r\n\t\tstring Mvid( Assembly a ) => a.ManifestModule.ModuleVersionId.ToString( \"N\" ).Substring( 0, 12 );\r\n\r\n\t\tstring LastWrite( Assembly a )\r\n\t\t{\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\treturn string.IsNullOrEmpty( a.Location ) || !File.Exists( a.Location )\r\n\t\t\t\t\t? null\r\n\t\t\t\t\t: File.GetLastWriteTime( a.Location ).ToString( \"yyyy-MM-dd HH:mm:ss\" );\r\n\t\t\t}\r\n\t\t\tcatch { return null; }\r\n\t\t}\r\n\r\n\t\tvar server = Assembly.GetExecutingAssembly();\r\n\t\tobject typeBuild = null;\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( typeName ) )\r\n\t\t{\r\n\t\t\tvar desc = Sandbox.Internal.GlobalToolsNamespace.EditorTypeLibrary.GetType( typeName );\r\n\t\t\tvar clr = desc?.TargetType\r\n\t\t\t\t?? throw new InvalidOperationException( $\"No type '{typeName}' - is it compiled? Check code_get_compile_errors\" );\r\n\r\n\t\t\tvar asm = clr.Assembly;\r\n\t\t\ttypeBuild = new\r\n\t\t\t{\r\n\t\t\t\ttype = clr.FullName,\r\n\t\t\t\tassembly = asm.GetName().Name,\r\n\t\t\t\tbuildId = Mvid( asm ),\r\n\t\t\t\tlocation = string.IsNullOrEmpty( asm.Location ) ? \"(in-memory / hot-loaded)\" : asm.Location,\r\n\t\t\t\tassemblyLastWrite = LastWrite( asm )\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tserverBuildId = Mvid( server ),\r\n\t\t\tserverAssembly = server.GetName().Name,\r\n\t\t\tserverLastWrite = LastWrite( server ),\r\n\t\t\ttype = typeBuild,\r\n\t\t\tnote = \"buildId (assembly MVID) changes on every recompile. Store it, recompile, call again: same buildId = the running process is still on the OLD build (stale); different = the new code is live.\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"code_delete_file\", \"Deletes a project source file (e.g. remove a component you no longer need). Jailed to the project; not undoable.\", ToolCategory.Code, Writes = true )]\r\n\tpublic static object DeleteFile( [Desc( \"Path relative to project root, e.g. 'Code/OldThing.cs'\" )] string path )\r\n\t{\r\n\t\tvar absolute = ResolveInProject( path );\r\n\t\tif ( !File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\"No file at '{path}' - use code_list_files\" );\r\n\r\n\t\tFile.Delete( absolute );\r\n\t\treturn new { deleted = path, note = \"the editor will hot-reload; check code_get_compile_errors for references you may need to remove\" };\r\n\t}\r\n\r\n\t[McpTool( \"compile_await\", \"Waits for code compilation to SETTLE after an edit, then reports compile errors and whether the running session hot-swapped the new code. Call this right after code_write_file/code_edit_file instead of code_get_compile_errors - it fixes the log-race (compile_errors can read clean before compilation finishes) and makes an invisible hot-swap visible.\", ToolCategory.Code )]\r\n\tpublic static async Task<object> CompileAwait(\r\n\t\t[Desc( \"Max seconds to wait for compilation to go quiet\" )] int timeoutSeconds = 20 )\r\n\t{\r\n\t\tvar startHotload = SessionTracker.LastHotloadAt;\r\n\t\tvar deadline = DateTime.Now.AddSeconds( Math.Clamp( timeoutSeconds, 1, 120 ) );\r\n\r\n\t\tvar lastSeq = LogCapture.LatestSeq;\r\n\t\tvar lastActivity = DateTime.Now;\r\n\t\tvar hotSwapped = false;\r\n\t\tvar settled = false;\r\n\r\n\t\t// wait until the console log stream goes quiet (compilation finished\r\n\t\t// emitting diagnostics); note a hotload if the loaded assembly changed\r\n\t\twhile ( DateTime.Now < deadline )\r\n\t\t{\r\n\t\t\tawait Task.Delay( 200 );\r\n\r\n\t\t\tif ( SessionTracker.LastHotloadAt is DateTime h && h != startHotload )\r\n\t\t\t\thotSwapped = true;\r\n\r\n\t\t\tvar seq = LogCapture.LatestSeq;\r\n\t\t\tif ( seq != lastSeq )\r\n\t\t\t{\r\n\t\t\t\tlastSeq = seq;\r\n\t\t\t\tlastActivity = DateTime.Now;\r\n\t\t\t}\r\n\t\t\telse if ( (DateTime.Now - lastActivity).TotalMilliseconds >= 1200 )\r\n\t\t\t{\r\n\t\t\t\tsettled = true;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// only C# COMPILE errors (error CSxxxx) - not engine resource-load errors\r\n\t\t// which also contain the word \"error\"\r\n\t\tvar errors = LogCapture.Recent( 300 )\r\n\t\t\t.Where( l => l.Message is not null && l.Message.Contains( \"error CS\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( l => l.Message )\r\n\t\t\t.Distinct()\r\n\t\t\t.Take( 25 )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn (object)new\r\n\t\t{\r\n\t\t\tsettled,\r\n\t\t\thotSwapped,\r\n\t\t\tclean = errors.Length == 0,\r\n\t\t\terrorCount = errors.Length,\r\n\t\t\terrors,\r\n\t\t\t// MVID of the running server assembly - changes on every recompile, so a\r\n\t\t\t// caller can tell \"is the code I'm calling actually the build I just made?\"\r\n\t\t\t// apart without planting a throwaway Log.Info canary. Compare across calls.\r\n\t\t\tbuildId = Assembly.GetExecutingAssembly().ManifestModule.ModuleVersionId.ToString( \"N\" ).Substring( 0, 12 ),\r\n\t\t\tnote = !settled\r\n\t\t\t\t? \"Timed out before compilation went quiet - poll again or raise timeoutSeconds.\"\r\n\t\t\t\t: hotSwapped\r\n\t\t\t\t\t? \"Compilation settled and a hotload swapped the new code into the running process.\"\r\n\t\t\t\t\t: \"Compilation settled; no hotload observed (code may already be current, or an interface-shape change forced a full reload).\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"code_get_compile_errors\", \"Gets recent compiler errors and warnings from the editor console.\", ToolCategory.Code )]\r\n\tpublic static object GetCompileErrors( int max = 50 )\r\n\t{\r\n\t\tvar entries = LogCapture.Recent( max, \"warning\", diagnosticsOnly: true )\r\n\t\t\t.Select( l => new { time = l.Time.ToString( \"HH:mm:ss\" ), level = l.Level, logger = l.Logger, message = l.Message } )\r\n\t\t\t.ToArray();\r\n\r\n\t\t// fall back to error-looking log lines if no tagged diagnostics are buffered\r\n\t\tif ( entries.Length == 0 )\r\n\t\t{\r\n\t\t\tentries = LogCapture.Recent( max, \"error\" )\r\n\t\t\t\t.Where( l => l.Message is not null )\r\n\t\t\t\t.Select( l => new { time = l.Time.ToString( \"HH:mm:ss\" ), level = l.Level, logger = l.Logger, message = l.Message } )\r\n\t\t\t\t.ToArray();\r\n\t\t}\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tcount = entries.Length,\r\n\t\t\tnote = \"Entries come from the editor console log stream. An empty list right after code_write_file may mean compilation has not finished - wait a moment and call again.\",\r\n\t\t\tentries\r\n\t\t};\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/EditorTools.cs",
            "FileName": "EditorTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.Linq;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Integration;\r\nusing SboxMcp.Registry;\r\nusing SboxMcp.Server;\r\nusing static SboxMcp.Tools.ToolHelpers;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\npublic static class EditorTools\r\n{\r\n\t[McpTool( \"editor_get_logs\", \"Reads recent editor console output (newest first) - compile diagnostics, editor warnings/errors. NOTE: game-side Log.* emitted while play mode is running may not all appear here; to inspect play-mode state, read component values with component_get_property / get_component_property (they reflect the live play scene).\", ToolCategory.Editor )]\r\n\tpublic static object GetLogs(\r\n\t\tint count = 100,\r\n\t\t[Desc( \"Minimum severity: trace, info, warning or error\" )] string minSeverity = null,\r\n\t\t[Desc( \"Only entries newer than this cursor (pass back the 'cursor' from the previous call to poll incrementally instead of re-reading old lines)\" )] long sinceSeq = 0 )\r\n\t{\r\n\t\tvar logs = LogCapture.Recent( count, minSeverity, sinceSeq: sinceSeq )\r\n\t\t\t.Select( l => new { seq = l.Seq, time = l.Time.ToString( \"HH:mm:ss\" ), level = l.Level, logger = l.Logger, message = l.Message } )\r\n\t\t\t.ToArray();\r\n\r\n\t\t// cursor = newest sequence number; pass it as sinceSeq next call for a\r\n\t\t// clean \"only what's new\" tail\r\n\t\treturn new { count = logs.Length, cursor = LogCapture.LatestSeq, logs };\r\n\t}\r\n\r\n\t[McpTool( \"logs_search\", \"Searches the captured console log by regex, minimum severity, and time window - returns matches WITH their stack traces (invaluable for errors/exceptions). Cleaner than paging editor_get_logs when hunting a specific message.\", ToolCategory.Editor )]\r\n\tpublic static object LogsSearch(\r\n\t\t[Desc( \"Regex to match in the message; omit to match everything\" )] string pattern = null,\r\n\t\t[Desc( \"Minimum severity: trace, info, warning or error\" )] string minSeverity = null,\r\n\t\t[Desc( \"Only entries from the last N seconds; omit for the whole buffer\" )] int withinSeconds = 0,\r\n\t\tint max = 50 )\r\n\t{\r\n\t\tvar since = withinSeconds > 0 ? System.DateTime.Now.AddSeconds( -withinSeconds ) : (System.DateTime?)null;\r\n\r\n\t\tvar results = LogCapture.Search( pattern, minSeverity, max, since )\r\n\t\t\t.Select( l => new { seq = l.Seq, time = l.Time.ToString( \"HH:mm:ss\" ), level = l.Level, logger = l.Logger, message = l.Message, stack = l.Stack } )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = results.Length, cursor = LogCapture.LatestSeq, results };\r\n\t}\r\n\r\n\t[McpTool( \"editor_clear_logs\", \"Clears the captured console log buffer.\", ToolCategory.Editor )]\r\n\tpublic static object ClearLogs()\r\n\t{\r\n\t\tLogCapture.Clear();\r\n\t\treturn new { cleared = true };\r\n\t}\r\n\r\n\t[McpTool( \"editor_screenshot\", \"Captures what the game camera sees, as an image. DURING PLAY this is the player's live point of view (renders Game.ActiveScene through its active CameraComponent) - use it to see what the player sees. In edit mode it renders the edit scene's camera. For an arbitrary angle instead, use editor_screenshot_from. Needs an enabled CameraComponent.\", ToolCategory.Editor )]\r\n\tpublic static object Screenshot(\r\n\t\t[Desc( \"Image width in pixels\" )] int width = 1280,\r\n\t\t[Desc( \"Image height in pixels\" )] int height = 720 )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.IsPlaying && Game.ActiveScene is not null ? Game.ActiveScene : session.Scene;\r\n\r\n\t\tif ( scene.Camera is null )\r\n\t\t\tthrow new InvalidOperationException(\r\n\t\t\t\t\"The scene has no enabled CameraComponent to render from - add one with component_add\" );\r\n\r\n\t\twidth = Math.Clamp( width, 64, 4096 );\r\n\t\theight = Math.Clamp( height, 64, 4096 );\r\n\r\n\t\tvar pixmap = new Pixmap( width, height );\r\n\r\n\t\tif ( !scene.RenderToPixmap( pixmap ) )\r\n\t\t\tthrow new InvalidOperationException( \"Rendering failed - check editor_get_logs; ensure a valid camera, or try editor_screenshot_from\" );\r\n\r\n\t\tvar png = pixmap.GetPng();\r\n\t\treturn new RawMcpResult( McpResults.ImageContent(\r\n\t\t\tConvert.ToBase64String( png ),\r\n\t\t\t$\"{(session.IsPlaying ? \"game\" : \"scene\")} camera view, {width}x{height}\" ) );\r\n\t}\r\n\r\n\t[McpTool( \"editor_screenshot_from\", \"Renders the scene from an arbitrary viewpoint (no camera component needed) - use it to inspect what you built from any angle.\", ToolCategory.Editor )]\r\n\tpublic static object ScreenshotFrom(\r\n\t\t[Desc( \"Camera world position [x, y, z]\" )] float[] position,\r\n\t\t[Desc( \"Camera rotation [pitch, yaw, roll]; ignored when lookAt is set\" )] float[] rotation = null,\r\n\t\t[Desc( \"GameObject id/name to aim the camera at\" )] string lookAt = null,\r\n\t\tint width = 1280,\r\n\t\tint height = 720 )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.Scene;\r\n\r\n\t\twidth = Math.Clamp( width, 64, 4096 );\r\n\t\theight = Math.Clamp( height, 64, 4096 );\r\n\r\n\t\t// temporary camera, intentionally outside any undo scope\r\n\t\tvar go = scene.CreateObject();\r\n\t\ttry\r\n\t\t{\r\n\t\t\tgo.Name = \"__mcp_temp_camera\";\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\t\tif ( lookAt is not null )\r\n\t\t\t{\r\n\t\t\t\tvar target = FindGameObject( lookAt );\r\n\t\t\t\tgo.WorldRotation = Rotation.LookAt( target.WorldPosition - go.WorldPosition );\r\n\t\t\t}\r\n\t\t\telse if ( rotation is not null )\r\n\t\t\t{\r\n\t\t\t\tif ( rotation.Length != 3 )\r\n\t\t\t\t\tthrow new ArgumentException( \"'rotation' must be [pitch, yaw, roll]\" );\r\n\r\n\t\t\t\tgo.WorldRotation = Rotation.From( rotation[0], rotation[1], rotation[2] );\r\n\t\t\t}\r\n\r\n\t\t\tvar camera = go.Components.Create<CameraComponent>();\r\n\t\t\tvar pixmap = new Pixmap( width, height );\r\n\r\n\t\t\tif ( !camera.RenderToPixmap( pixmap ) )\r\n\t\t\t\tthrow new InvalidOperationException( \"Rendering failed\" );\r\n\r\n\t\t\treturn new RawMcpResult( McpResults.ImageContent(\r\n\t\t\t\tConvert.ToBase64String( pixmap.GetPng() ),\r\n\t\t\t\t$\"view from [{string.Join( \", \", position )}], {width}x{height}\" ) );\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tgo.Destroy();\r\n\t\t}\r\n\t}\r\n\r\n\t[McpTool( \"editor_frame_object\", \"Points the editor viewport camera at a GameObject so the user can see it.\", ToolCategory.Editor )]\r\n\tpublic static object FrameObject( [Desc( \"GameObject id or unique name\" )] string gameObject )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar go = FindGameObject( gameObject );\r\n\r\n\t\tsession.FrameTo( go.GetBounds() );\r\n\t\treturn new { framed = go.Name };\r\n\t}\r\n\r\n\t[McpTool( \"editor_play\", \"Enters play mode with the current scene.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object Play()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tif ( session.IsPlaying )\r\n\t\t\treturn new { playing = true, note = \"already in play mode\" };\r\n\r\n\t\tEditorScene.Play();\r\n\t\treturn new { playing = SceneEditorSession.Active?.IsPlaying ?? false };\r\n\t}\r\n\r\n\t[McpTool( \"editor_stop\", \"Exits play mode.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object Stop()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tif ( !session.IsPlaying )\r\n\t\t\treturn new { playing = false, note = \"was not in play mode\" };\r\n\r\n\t\tEditorScene.Stop();\r\n\t\treturn new { playing = false };\r\n\t}\r\n\r\n\t[McpTool( \"editor_is_playing\", \"Whether the editor is currently in play mode.\", ToolCategory.Editor )]\r\n\tpublic static object IsPlaying()\r\n\t{\r\n\t\treturn new { playing = SceneEditorSession.Active?.IsPlaying ?? false };\r\n\t}\r\n\r\n\t[McpTool( \"session_info\", \"Play-session identity and timing - use it to tell restarts apart (play clones reuse the editor's GUIDs, so 'did the scene restart?' is otherwise a guess): whether play mode is running, when the current play session started, a play-session counter, when code last hot-reloaded, and when the MCP server started.\", ToolCategory.Editor )]\r\n\tpublic static object SessionInfo()\r\n\t{\r\n\t\tstring Stamp( System.DateTime? t ) => t?.ToString( \"yyyy-MM-dd HH:mm:ss\" );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tplaying = SboxMcp.Integration.SessionTracker.IsPlaying,\r\n\t\t\tplaySessionCount = SboxMcp.Integration.SessionTracker.PlaySessionCount,\r\n\t\t\tplayStartedAt = Stamp( SboxMcp.Integration.SessionTracker.PlayStartedAt ),\r\n\t\t\tlastHotloadAt = Stamp( SboxMcp.Integration.SessionTracker.LastHotloadAt ),\r\n\t\t\tserverStartedAt = Stamp( SboxMcp.Integration.SessionTracker.ServerStartedAt )\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"perf_get_stats\", \"Measures the frame rate over a short window (by sampling the editor frame counter) and reports FPS + average frame time - use it to quantitatively confirm a perf fix (e.g. removing debug-draw overdraw) instead of eyeballing sphere counts. During play this reflects the running game's tick loop.\", ToolCategory.Editor )]\r\n\tpublic static async Task<object> PerfGetStats(\r\n\t\t[Desc( \"Measurement window in seconds (0.2-10)\" )] double seconds = 1.0 )\r\n\t{\r\n\t\tseconds = Math.Clamp( seconds, 0.2, 10 );\r\n\r\n\t\tvar startFrames = SessionTracker.FrameCount;\r\n\t\tvar startTime = DateTime.Now;\r\n\t\tawait Task.Delay( (int)(seconds * 1000) );\r\n\t\tvar elapsed = (DateTime.Now - startTime).TotalSeconds;\r\n\t\tvar frames = SessionTracker.FrameCount - startFrames;\r\n\t\tvar fps = elapsed > 0 ? frames / elapsed : 0;\r\n\r\n\t\treturn (object)new\r\n\t\t{\r\n\t\t\tfps = Math.Round( fps, 1 ),\r\n\t\t\tframeTimeMs = fps > 0 ? (object)Math.Round( 1000.0 / fps, 2 ) : null,\r\n\t\t\tframes,\r\n\t\t\twindowSeconds = Math.Round( elapsed, 2 ),\r\n\t\t\tplaying = SessionTracker.IsPlaying,\r\n\t\t\tnote = \"FPS is the editor frame loop (which is the game tick loop during play). GPU draw-call counters aren't exposed by the editor API. Measure before and after a change to compare.\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"editor_run_console_command\", \"Runs an editor console command (e.g. 'clear', convars).\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object RunConsoleCommand( [Desc( \"The console command line to run\" )] string command )\r\n\t{\r\n\t\tEditor.ConsoleSystem.Run( command );\r\n\t\treturn new { ran = command, note = \"check editor_get_logs for output\" };\r\n\t}\r\n\r\n\t[McpTool( \"convar_get\", \"Reads a console variable's value (game/engine settings).\", ToolCategory.Editor )]\r\n\tpublic static object ConVarGet( [Desc( \"ConVar name, e.g. 'sv_gravity'\" )] string name )\r\n\t{\r\n\t\tvar value = Sandbox.ConsoleSystem.GetValue( name, null );\r\n\t\tif ( value is null )\r\n\t\t\tthrow new InvalidOperationException( $\"No console variable '{name}' - check the exact name with editor_run_console_command 'find {name}'\" );\r\n\r\n\t\treturn new { name, value };\r\n\t}\r\n\r\n\t[McpTool( \"convar_set\", \"Sets a console variable's value.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object ConVarSet(\r\n\t\t[Desc( \"ConVar name\" )] string name,\r\n\t\t[Desc( \"New value (string)\" )] string value )\r\n\t{\r\n\t\tSandbox.ConsoleSystem.SetValue( name, value );\r\n\t\treturn new { name, value = Sandbox.ConsoleSystem.GetValue( name, value ) };\r\n\t}\r\n\r\n\t[McpTool( \"editor_get_project_info\", \"Gets the current project: title, ident, type, paths.\", ToolCategory.Editor )]\r\n\tpublic static object GetProjectInfo()\r\n\t{\r\n\t\tvar project = Project.Current\r\n\t\t\t?? throw new InvalidOperationException( \"No project is loaded\" );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\ttitle = project.Config?.Title,\r\n\t\t\tident = project.Config?.Ident,\r\n\t\t\torg = project.Config?.Org,\r\n\t\t\ttype = project.Config?.Type,\r\n\t\t\trootPath = project.GetRootPath(),\r\n\t\t\thasCode = project.HasCodePath(),\r\n\t\t\thasEditorCode = project.HasEditorPath()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"editor_get_selection\", \"Gets the GameObjects currently selected in the editor.\", ToolCategory.Editor )]\r\n\tpublic static object GetSelection()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar selected = session.Selection.OfType<GameObject>()\r\n\t\t\t.Select( o => new { id = o.Id, name = o.Name } )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = selected.Length, selected };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/ProjectTools.cs",
            "FileName": "ProjectTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text.Json.Nodes;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Registry;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\n/// <summary>\r\n/// Project configuration: input actions and startup scene.\r\n/// </summary>\r\npublic static class ProjectTools\r\n{\r\n\t[McpTool( \"input_list_actions\", \"Lists the project's input actions (the names used with Input.Pressed/Down in code).\", ToolCategory.Editor )]\r\n\tpublic static object ListActions()\r\n\t{\r\n\t\tvar settings = ProjectSettings.Input\r\n\t\t\t?? throw new InvalidOperationException( \"Input settings are unavailable - is a project loaded?\" );\r\n\r\n\t\tvar actions = (settings.Actions ?? new())\r\n\t\t\t.Select( a => new { name = a.Name, group = a.GroupName, keyboard = a.KeyboardCode, gamepad = a.GamepadCode.ToString() } )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = actions.Length, actions };\r\n\t}\r\n\r\n\t[McpTool( \"input_add_action\", \"Adds an input action to the project (use the name with Input.Pressed in code). Applies on next play.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object AddAction(\r\n\t\t[Desc( \"Action name, e.g. 'Dash'\" )] string name,\r\n\t\t[Desc( \"Keyboard key, e.g. 'shift', 'e', 'mouse1'\" )] string keyboardCode,\r\n\t\t[Desc( \"Group shown in settings UI, e.g. 'Movement'\" )] string group = \"Other\" )\r\n\t{\r\n\t\tvar settings = ProjectSettings.Input\r\n\t\t\t?? throw new InvalidOperationException( \"Input settings are unavailable - is a project loaded?\" );\r\n\r\n\t\tsettings.Actions ??= new();\r\n\r\n\t\tif ( !System.Text.RegularExpressions.Regex.IsMatch( name ?? \"\", @\"^[a-zA-Z0-9_\\-]+$\" ) )\r\n\t\t\tthrow new ArgumentException( \"Action name may only contain letters, digits, underscore and hyphen (no spaces)\" );\r\n\r\n\t\tif ( settings.Actions.Any( a => string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\tthrow new InvalidOperationException( $\"An input action named '{name}' already exists - input_list_actions shows it; remove it first with input_remove_action\" );\r\n\r\n\t\tsettings.Actions.Add( new InputAction { Name = name, KeyboardCode = keyboardCode, GroupName = group } );\r\n\t\tSaveInputSettings( settings );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tadded = name,\r\n\t\t\tkeyboard = keyboardCode,\r\n\t\t\tgroup,\r\n\t\t\tnote = \"IMPORTANT: input action bindings register on the NEXT play session, not the current one. If you're already in play mode, editor_stop then editor_play (or restart) before the binding works - otherwise Input.Pressed(\\\"\" + name + \"\\\") silently returns false.\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"input_remove_action\", \"Removes an input action from the project.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object RemoveAction( [Desc( \"Action name\" )] string name )\r\n\t{\r\n\t\tvar settings = ProjectSettings.Input\r\n\t\t\t?? throw new InvalidOperationException( \"Input settings are unavailable - is a project loaded?\" );\r\n\r\n\t\tvar action = settings.Actions?.FirstOrDefault( a => string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t?? throw new InvalidOperationException( $\"No input action named '{name}' - use input_list_actions\" );\r\n\r\n\t\tsettings.Actions.Remove( action );\r\n\t\tSaveInputSettings( settings );\r\n\r\n\t\treturn new { removed = action.Name };\r\n\t}\r\n\r\n\tstatic void SaveInputSettings( InputSettings settings )\r\n\t{\r\n\t\tvar root = AssetTools.ProjectRoot;\r\n\t\tvar dir = Path.Combine( root, \"ProjectSettings\" );\r\n\t\tDirectory.CreateDirectory( dir );\r\n\t\t// use the config's own Serialize so the __schema/__version header is\r\n\t\t// written the way the engine expects (keeps upgraders working)\r\n\t\tFile.WriteAllText( Path.Combine( dir, \"Input.config\" ),\r\n\t\t\tsettings.Serialize().ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );\r\n\t}\r\n\r\n\t[McpTool( \"project_set_startup_scene\", \"Sets the scene the game opens with when launched.\", ToolCategory.Editor, Writes = true )]\r\n\tpublic static object SetStartupScene( [Desc( \"Scene asset path, e.g. 'scenes/main_menu.scene'\" )] string scenePath )\r\n\t{\r\n\t\tvar asset = AssetSystem.FindByPath( scenePath )\r\n\t\t\t?? throw new InvalidOperationException( $\"No scene at '{scenePath}' - use scene_list\" );\r\n\r\n\t\tvar root = AssetTools.ProjectRoot;\r\n\t\tvar sbproj = Directory.GetFiles( root, \"*.sbproj\" ).FirstOrDefault()\r\n\t\t\t?? throw new InvalidOperationException( \"No .sbproj file found in the project root\" );\r\n\r\n\t\tvar json = JsonNode.Parse( File.ReadAllText( sbproj ) ) as JsonObject\r\n\t\t\t?? throw new InvalidOperationException( \"Could not parse the .sbproj file\" );\r\n\r\n\t\tvar metadata = json[\"Metadata\"] as JsonObject;\r\n\t\tif ( metadata is null )\r\n\t\t\tjson[\"Metadata\"] = metadata = new JsonObject();\r\n\r\n\t\tmetadata[\"StartupScene\"] = asset.Path;\r\n\t\tFile.WriteAllText( sbproj, json.ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );\r\n\r\n\t\treturn new { startupScene = asset.Path };\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "notpointless.chomnr_mcp",
            "Path": "Editor/Tools/SceneTools.cs",
            "FileName": "SceneTools.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 311798,
            "Code": "using System;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\nusing SboxMcp.Registry;\r\nusing static SboxMcp.Tools.ToolHelpers;\r\n\r\nnamespace SboxMcp.Tools;\r\n\r\npublic static class SceneTools\r\n{\r\n\t[McpTool( \"scene_get_status\", \"Gets the active scene: name, play state, unsaved changes, object count.\", ToolCategory.Scene )]\r\n\tpublic static object GetStatus()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.Scene;\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tname = scene.Name,\r\n\t\t\tisPlaying = session.IsPlaying,\r\n\t\t\tsceneTarget = ToolHelpers.SceneTargetMode ?? \"active\",\r\n\t\t\thasUnsavedChanges = session.HasUnsavedChanges,\r\n\t\t\tobjectCount = scene.GetAllObjects( false ).Count( o => o is not Sandbox.Scene ),\r\n\t\t\tselection = session.Selection.OfType<Sandbox.GameObject>().Select( o => new { id = o.Id, name = o.Name } ).ToArray()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"scene_setup_basic\", \"Bootstraps a usable scene in the current scene: a ground plane (with a collider), a directional light, and a camera - so you can start building and playing immediately.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SetupBasic(\r\n\t\t[Desc( \"Ground size multiplier (scales a dev box)\" )] float groundScale = 10f )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar box = Model.Load( \"models/dev/box.vmdl\" );\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: setup basic scene\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar ground = session.Scene.CreateObject();\r\n\t\tground.Name = \"Ground\";\r\n\t\tground.LocalScale = new Vector3( groundScale, groundScale, 1f );\r\n\t\tground.Components.Create<ModelRenderer>().Model = box;\r\n\t\t// A BoxCollider (primitive), NOT a ModelCollider: the dev box model has\r\n\t\t// no collision mesh, so a ModelCollider would leave the ground non-solid\r\n\t\t// and objects would fall straight through it.\r\n\t\tground.Components.Create<BoxCollider>();\r\n\r\n\t\tvar sun = session.Scene.CreateObject();\r\n\t\tsun.Name = \"Sun\";\r\n\t\tsun.WorldRotation = Rotation.From( 60, 45, 0 );\r\n\t\tsun.Components.Create<DirectionalLight>();\r\n\r\n\t\tvar cam = session.Scene.CreateObject();\r\n\t\tcam.Name = \"Camera\";\r\n\t\tcam.WorldPosition = new Vector3( -350, 0, 200 );\r\n\t\tcam.WorldRotation = Rotation.From( 25, 0, 0 );\r\n\t\tcam.Components.Create<CameraComponent>().FieldOfView = 70f;\r\n\r\n\t\treturn new { created = new[] { \"Ground\", \"Sun\", \"Camera\" }, note = \"ground has a collider; a directional light and camera are set - ready to build and play\" };\r\n\t}\r\n\r\n\t[McpTool( \"scene_diff\", \"Compares the in-memory editor scene to its saved .scene file on disk: reports unsaved changes and which top-level GameObjects were added or removed since the last save. Review it before scene_save to catch an accidental overwrite (e.g. saving over the wrong scene) and to make deliberate saves reviewable.\", ToolCategory.Scene )]\r\n\tpublic static object SceneDiff()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.Scene;\r\n\r\n\t\tvar memObjects = scene.Children.Where( o => o is not Sandbox.Scene ).Select( o => o.Name ).ToArray();\r\n\r\n\t\tstring scenePath = null;\r\n\t\tstring[] diskObjects = null;\r\n\t\tstring diskNote = null;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tscenePath = scene.Source?.ResourcePath;\r\n\t\t\tvar file = string.IsNullOrEmpty( scenePath ) ? null : AssetSystem.FindByPath( scenePath )?.GetSourceFile( true );\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( file ) && File.Exists( file ) )\r\n\t\t\t{\r\n\t\t\t\tusing var doc = System.Text.Json.JsonDocument.Parse( File.ReadAllText( file ) );\r\n\t\t\t\tif ( doc.RootElement.TryGetProperty( \"GameObjects\", out var arr ) && arr.ValueKind == System.Text.Json.JsonValueKind.Array )\r\n\t\t\t\t{\r\n\t\t\t\t\tdiskObjects = arr.EnumerateArray()\r\n\t\t\t\t\t\t.Select( e => e.TryGetProperty( \"Name\", out var n ) ? n.GetString() : null )\r\n\t\t\t\t\t\t.Where( n => n is not null )\r\n\t\t\t\t\t\t.ToArray();\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tdiskNote = \"scene has not been saved to disk yet (or its source file was not found)\";\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tdiskNote = \"could not read/parse the disk scene: \" + e.Message;\r\n\t\t}\r\n\r\n\t\tvar added = diskObjects is null ? null : memObjects.Except( diskObjects ).ToArray();\r\n\t\tvar removed = diskObjects is null ? null : diskObjects.Except( memObjects ).ToArray();\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tscene = scene.Name,\r\n\t\t\tscenePath,\r\n\t\t\thasUnsavedChanges = session.HasUnsavedChanges,\r\n\t\t\tinMemoryObjects = memObjects.Length,\r\n\t\t\tonDiskObjects = diskObjects?.Length,\r\n\t\t\taddedSinceSave = added,\r\n\t\t\tremovedSinceSave = removed,\r\n\t\t\tnote = diskNote ?? (session.HasUnsavedChanges\r\n\t\t\t\t? \"In-memory scene differs from disk - scene_save to persist (or you may lose these changes on restart).\"\r\n\t\t\t\t: \"In-memory scene matches the last save.\")\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"scene_target\", \"Chooses which scene the object/component tools act on while PLAY mode is running: 'editor' = the persistent edit scene (plant a toggle/route/prop that survives Stop and restarts - the fix for losing objects to restarts), 'play' = the live throwaway play clone, 'active' (default) = whatever is focused. Set 'editor' before planting persistent objects during play, then reset to 'active'. No effect when not playing.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SetSceneTarget( [Desc( \"'editor', 'play', or 'active'\" )] string target = \"active\" )\r\n\t{\r\n\t\tvar t = (target ?? \"active\").ToLowerInvariant();\r\n\t\tif ( t is not (\"editor\" or \"play\" or \"active\") )\r\n\t\t\tthrow new ArgumentException( \"target must be 'editor', 'play', or 'active'\" );\r\n\r\n\t\tToolHelpers.SceneTargetMode = t == \"active\" ? null : t;\r\n\r\n\t\tvar resolved = RequireSession();\r\n\t\treturn new\r\n\t\t{\r\n\t\t\ttarget = t,\r\n\t\t\tresolvedScene = resolved.Scene?.Name,\r\n\t\t\tresolvedIsPlaying = resolved.IsPlaying,\r\n\t\t\tnote = \"Applies to subsequent object/component tools until changed. Reset to 'active' when done.\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"scene_load_map\", \"Imports a map into the scene by creating a GameObject with a MapInstance component - loads Hammer/Source2 .vmap geometry as a level. Set mapName to a map asset path like 'maps/mylevel.vmap' (find them with asset_search assetType 'vmap').\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object LoadMap(\r\n\t\t[Desc( \"Map asset name/path, e.g. 'maps/mylevel.vmap'\" )] string mapName,\r\n\t\t[Desc( \"Name for the map GameObject\" )] string objectName = \"Map\",\r\n\t\t[Desc( \"World origin [x, y, z] for the map\" )] float[] position = null )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( mapName ) )\r\n\t\t\tthrow new ArgumentException( \"mapName is required (e.g. 'maps/mylevel.vmap')\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: load map\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( objectName ) ? \"Map\" : objectName;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \"position\" );\r\n\r\n\t\tvar map = go.Components.Create<MapInstance>();\r\n\t\tmap.MapName = mapName;\r\n\r\n\t\treturn new { loaded = mapName, gameObject = go.Name, id = go.Id, isLoaded = map.IsLoaded };\r\n\t}\r\n\r\n\t[McpTool( \"scene_add_asset\", \"Adds any asset to the scene, dispatching by type: a model (.vmdl) -> GameObject with a ModelRenderer; a prefab (.prefab) -> instantiated; a map (.vmap) -> GameObject with a MapInstance. The one-call 'put this asset in the scene'. For materials/textures/sounds (which aren't scene objects), apply them to a component instead.\", ToolCategory.Asset, Writes = true )]\r\n\tpublic static object AddAsset(\r\n\t\t[Desc( \"Asset path, e.g. 'models/x.vmdl', 'prefabs/y.prefab', 'maps/z.vmap'\" )] string path,\r\n\t\t[Desc( \"Object name; defaults to the asset's file name\" )] string name = null,\r\n\t\t[Desc( \"World position [x, y, z]\" )] float[] position = null )\r\n\t{\r\n\t\tif ( AssetSystem.FindByPath( path ) is null )\r\n\t\t\tthrow new InvalidOperationException( $\"No asset at '{path}' - use asset_search to find it\" );\r\n\r\n\t\tvar session = RequireSession();\r\n\t\tvar pos = position is null ? Vector3.Zero : ToVector3( position, \"position\" );\r\n\t\tvar displayName = string.IsNullOrWhiteSpace( name ) ? Path.GetFileNameWithoutExtension( path ) : name;\r\n\t\tvar ext = Path.GetExtension( path ).ToLowerInvariant();\r\n\r\n\t\tusing var undo = session.UndoScope( \"MCP: add asset\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tswitch ( ext )\r\n\t\t{\r\n\t\t\tcase \".vmdl\":\r\n\t\t\t{\r\n\t\t\t\tvar go = session.Scene.CreateObject();\r\n\t\t\t\tgo.Name = displayName;\r\n\t\t\t\tgo.WorldPosition = pos;\r\n\t\t\t\tgo.Components.Create<ModelRenderer>().Model = Model.Load( path );\r\n\t\t\t\treturn new { added = \"model\", gameObject = go.Name, id = go.Id };\r\n\t\t\t}\r\n\t\t\tcase \".prefab\":\r\n\t\t\t{\r\n\t\t\t\tvar prefabFile = ResourceLibrary.Get<PrefabFile>( path )\r\n\t\t\t\t\t?? throw new InvalidOperationException( $\"Prefab '{path}' could not be loaded\" );\r\n\t\t\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t\t\t?? throw new InvalidOperationException( $\"Prefab '{path}' could not be loaded\" );\r\n\t\t\t\tvar instance = prefabScene.Clone( new Transform( pos ) );\r\n\t\t\t\treturn new { added = \"prefab\", gameObject = instance.Name, id = instance.Id };\r\n\t\t\t}\r\n\t\t\tcase \".vmap\":\r\n\t\t\t{\r\n\t\t\t\tvar go = session.Scene.CreateObject();\r\n\t\t\t\tgo.Name = displayName;\r\n\t\t\t\tgo.WorldPosition = pos;\r\n\t\t\t\tgo.Components.Create<MapInstance>().MapName = path;\r\n\t\t\t\treturn new { added = \"map\", gameObject = go.Name, id = go.Id };\r\n\t\t\t}\r\n\t\t\tdefault:\r\n\t\t\t\tthrow new InvalidOperationException(\r\n\t\t\t\t\t$\"Don't know how to add a '{ext}' asset as a scene object. Supported: .vmdl (model), .prefab, .vmap (map). Materials/textures/sounds are applied to components (material_create, component_set_property, sound_play), not added as objects.\" );\r\n\t\t}\r\n\t}\r\n\r\n\t[McpTool( \"navmesh_generate\", \"Enables and bakes the scene's NavMesh from its static/ground colliders so NPCs and enemies can pathfind. Set the agent size to match your characters. Run after the level geometry exists.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object NavMeshGenerate(\r\n\t\t[Desc( \"Agent radius (character half-width)\" )] float agentRadius = 16f,\r\n\t\t[Desc( \"Agent height\" )] float agentHeight = 72f,\r\n\t\t[Desc( \"Max step height the agent can climb\" )] float agentStepSize = 18f )\r\n\t{\r\n\t\tvar scene = RequireScene();\r\n\t\tvar nav = scene.NavMesh\r\n\t\t\t?? throw new InvalidOperationException( \"This scene has no NavMesh object\" );\r\n\r\n\t\tnav.IsEnabled = true;\r\n\t\tnav.AgentRadius = agentRadius;\r\n\t\tnav.AgentHeight = agentHeight;\r\n\t\tnav.AgentStepSize = agentStepSize;\r\n\t\tnav.Generate( scene.PhysicsWorld );\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tenabled = true,\r\n\t\t\tagentRadius,\r\n\t\t\tagentHeight,\r\n\t\t\tisGenerating = nav.IsGenerating,\r\n\t\t\tnote = \"generation may finish asynchronously; query paths with navmesh_find_path\"\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"navmesh_find_path\", \"Finds a navigation path between two world points on the scene's NavMesh (for NPC/enemy movement) - returns the waypoints. Requires navmesh_generate first.\", ToolCategory.Scene )]\r\n\tpublic static object NavMeshFindPath(\r\n\t\t[Desc( \"Start point [x, y, z]\" )] float[] from,\r\n\t\t[Desc( \"Destination point [x, y, z]\" )] float[] to )\r\n\t{\r\n\t\tvar scene = RequireScene();\r\n\t\tvar nav = scene.NavMesh;\r\n\t\tif ( nav is null || !nav.IsEnabled )\r\n\t\t\tthrow new InvalidOperationException( \"The scene's NavMesh is not enabled - call navmesh_generate first\" );\r\n\r\n\t\tvar target = ToVector3( to, \"to\" );\r\n\t\tvar path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest\r\n\t\t{\r\n\t\t\tStart = ToVector3( from, \"from\" ),\r\n\t\t\tTarget = target\r\n\t\t} );\r\n\r\n\t\tvar points = path.Points is null ? Array.Empty<float[]>() : path.Points.Select( p => V( p.Position ) ).ToArray();\r\n\t\tvar reaches = path.Status == Sandbox.Navigation.NavMeshPathStatus.Complete;\r\n\r\n\t\t// a Partial path's LAST waypoint is the closest reachable point, which the\r\n\t\t// engine leaves short of the target - callers must gate on `reaches`, never\r\n\t\t// on distance-to-last-point, or they'll treat unreachable targets as reached\r\n\t\tvar lastPos = points.Length > 0 ? path.Points.Last().Position : (Vector3?)null;\r\n\t\tvar endsAt = lastPos.HasValue ? V( lastPos.Value ) : null;\r\n\t\tvar gap = lastPos.HasValue ? Vector3.DistanceBetween( lastPos.Value, target ) : (float?)null;\r\n\r\n\t\treturn new\r\n\t\t{\r\n\t\t\treaches,                 // TRUE only when the target is actually reachable\r\n\t\t\tfound = reaches,         // kept for back-compat\r\n\t\t\tstatus = path.Status.ToString(),\r\n\t\t\twaypoints = points.Length,\r\n\t\t\tendsAt,                  // real endpoint of the path (may be short of the target)\r\n\t\t\trequestedEnd = V( target ),\r\n\t\t\tendpointGap = gap.HasValue ? (object)Math.Round( gap.Value, 2 ) : null,\r\n\t\t\tpoints,\r\n\t\t\tnote = reaches\r\n\t\t\t\t? null\r\n\t\t\t\t: \"PARTIAL/failed path: the target is NOT reachable. 'endsAt' is the closest reachable point (endpointGap units short) - do not treat it as the destination. Gate movement/AI logic on 'reaches'.\"\r\n\t\t};\r\n\t}\r\n\r\n\tstatic Sandbox.Navigation.NavMesh RequireNav()\r\n\t{\r\n\t\tvar nav = RequireScene().NavMesh;\r\n\t\tif ( nav is null || !nav.IsEnabled )\r\n\t\t\tthrow new InvalidOperationException( \"The scene's NavMesh is not enabled - call navmesh_generate first\" );\r\n\r\n\t\treturn nav;\r\n\t}\r\n\r\n\t[McpTool( \"navmesh_random_point\", \"Returns a random reachable point on the scene's NavMesh - for AI wander targets. Optionally sampled near a position within a radius. Requires navmesh_generate first.\", ToolCategory.Scene )]\r\n\tpublic static object NavMeshRandomPoint(\r\n\t\t[Desc( \"Center to sample near [x, y, z]; omit for anywhere on the navmesh\" )] float[] near = null,\r\n\t\t[Desc( \"Sample radius around 'near'\" )] float radius = 500f )\r\n\t{\r\n\t\tvar nav = RequireNav();\r\n\t\tvar point = near is not null ? nav.GetRandomPoint( ToVector3( near, \"near\" ), radius ) : nav.GetRandomPoint();\r\n\r\n\t\treturn point is null\r\n\t\t\t? new { found = false, point = (float[])null }\r\n\t\t\t: new { found = true, point = V( point.Value ) };\r\n\t}\r\n\r\n\t[McpTool( \"navmesh_closest_point\", \"Snaps a world point to the nearest point on the scene's NavMesh within a radius (clamp a spawn/target onto walkable ground). Requires navmesh_generate first.\", ToolCategory.Scene )]\r\n\tpublic static object NavMeshClosestPoint(\r\n\t\t[Desc( \"World point [x, y, z]\" )] float[] position,\r\n\t\t[Desc( \"Search radius\" )] float radius = 200f )\r\n\t{\r\n\t\tvar nav = RequireNav();\r\n\t\tvar point = nav.GetClosestPoint( ToVector3( position, \"position\" ), radius );\r\n\r\n\t\treturn point is null\r\n\t\t\t? new { found = false, point = (float[])null }\r\n\t\t\t: new { found = true, point = V( point.Value ) };\r\n\t}\r\n\r\n\t[McpTool( \"scene_get_hierarchy\", \"Gets the scene's GameObject tree with ids, names and component types.\", ToolCategory.Scene )]\r\n\tpublic static object GetHierarchy(\r\n\t\t[Desc( \"How many levels deep to expand\" )] int maxDepth = 4,\r\n\t\t[Desc( \"Id of a GameObject to use as the root; omit for the whole scene\" )] string rootId = null )\r\n\t{\r\n\t\tif ( rootId is not null )\r\n\t\t\treturn DescribeTree( FindGameObject( rootId ), maxDepth );\r\n\r\n\t\tvar scene = RequireScene();\r\n\t\treturn new\r\n\t\t{\r\n\t\t\tscene = scene.Name,\r\n\t\t\tobjects = scene.Children.Select( c => DescribeTree( c, maxDepth - 1 ) ).ToArray()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \"scene_create\", \"Creates a new scene (with a camera and a light) and makes it active. Save it with scene_save_as.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object Create()\r\n\t{\r\n\t\tvar session = SceneEditorSession.CreateDefault();\r\n\t\tsession.MakeActive();\r\n\t\treturn new { created = session.Scene.Name, note = \"unsaved - use scene_save_as to write it to disk\" };\r\n\t}\r\n\r\n\t[McpTool( \"scene_open\", \"Opens a scene (or prefab) from disk in the editor and makes it active.\", ToolCategory.Scene )]\r\n\tpublic static object Open( [Desc( \"Scene asset path, e.g. 'scenes/minimal.scene'\" )] string scenePath )\r\n\t{\r\n\t\tvar session = SceneEditorSession.CreateFromPath( scenePath )\r\n\t\t\t?? throw new InvalidOperationException( $\"No scene at '{scenePath}' - use scene_list\" );\r\n\r\n\t\tsession.MakeActive();\r\n\t\treturn new { opened = session.Scene.Name };\r\n\t}\r\n\r\n\t[McpTool( \"scene_list\", \"Lists all scene assets in the project.\", ToolCategory.Scene )]\r\n\tpublic static object List()\r\n\t{\r\n\t\tvar scenes = AssetSystem.All\r\n\t\t\t.Where( a => string.Equals( a.AssetType?.FileExtension, \"scene\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( a => a.Path )\r\n\t\t\t.OrderBy( p => p )\r\n\t\t\t.ToArray();\r\n\r\n\t\treturn new { count = scenes.Length, scenes };\r\n\t}\r\n\r\n\t[McpTool( \"scene_save\", \"Saves the active scene to disk. Fails for never-saved scenes - use scene_save_as for those.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object Save()\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tif ( session.IsPlaying )\r\n\t\t\tthrow new InvalidOperationException( \"Cannot save while playing - editor_stop first (play-mode changes are discarded by design)\" );\r\n\r\n\t\tif ( session.Scene.Source is null )\r\n\t\t\tthrow new InvalidOperationException( \"This scene has never been saved - use scene_save_as with a path\" );\r\n\r\n\t\tsession.Save( false );\r\n\t\treturn new { saved = true, scene = session.Scene.Name };\r\n\t}\r\n\r\n\t[McpTool( \"scene_save_as\", \"Saves the active scene to a new path under Assets/ (works for never-saved scenes).\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SaveAs( [Desc( \"Assets-relative path ending in .scene, e.g. 'scenes/level1.scene'\" )] string scenePath )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.Scene;\r\n\r\n\t\tif ( session.IsPlaying )\r\n\t\t\tthrow new InvalidOperationException( \"Cannot save while playing - editor_stop first (play-mode changes are discarded by design)\" );\r\n\r\n\t\tif ( scene is PrefabScene )\r\n\t\t\tthrow new InvalidOperationException( \"The active session is a prefab - prefabs save with scene_save, or use prefab_create_from_gameobject\" );\r\n\r\n\t\tif ( !scenePath.EndsWith( \".scene\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new ArgumentException( \"scenePath must end in .scene\" );\r\n\r\n\t\tvar absolute = AssetTools.ResolveNewAssetPath( scenePath );\r\n\t\tSystem.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );\r\n\r\n\t\tvar asset = AssetSystem.CreateResource( \"scene\", absolute )\r\n\t\t\t?? throw new InvalidOperationException( $\"Could not create a scene resource at '{scenePath}' - is the path inside the project?\" );\r\n\r\n\t\t// mirror of SceneEditorSession.Save: Scene.CreateSceneFile() is internal,\r\n\t\t// so reach it via reflection (same flow the editor's own Ctrl+S runs)\r\n\t\tvar createSceneFile = typeof( Scene ).GetMethod( \"CreateSceneFile\",\r\n\t\t\tSystem.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic )\r\n\t\t\t?? throw new InvalidOperationException( \"Scene.CreateSceneFile not found - the engine changed; report this\" );\r\n\r\n\t\tvar resource = (Sandbox.GameResource)createSceneFile.Invoke( scene, null );\r\n\t\tasset.SaveToDisk( resource );\r\n\r\n\t\t// Scene.Source's setter is internal - reflection again, matching the editor's save flow\r\n\t\ttypeof( Scene ).GetProperty( \"Source\" )?.SetValue( scene, resource );\r\n\t\tscene.Name = System.IO.Path.GetFileNameWithoutExtension( absolute );\r\n\t\tsession.HasUnsavedChanges = false;\r\n\r\n\t\treturn new { saved = asset.Path };\r\n\t}\r\n\r\n\t[McpTool( \"scene_undo\", \"Undoes the last editor action.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object Undo()\r\n\t{\r\n\t\tvar ok = RequireSession().UndoSystem.Undo();\r\n\t\treturn new { undone = ok };\r\n\t}\r\n\r\n\t[McpTool( \"scene_redo\", \"Redoes the last undone editor action.\", ToolCategory.Scene, Writes = true )]\r\n\tpublic static object Redo()\r\n\t{\r\n\t\tvar ok = RequireSession().UndoSystem.Redo();\r\n\t\treturn new { redone = ok };\r\n\t}\r\n}\r\n"
        }
    ]
}