{"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/// \u003Csummary\u003E\r\n/// Marks a static method as an MCP tool. The registry reflects the method\u0027s\r\n/// parameters into a JSON Schema and exposes it via tools/list.\r\n/// \u003C/summary\u003E\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/// \u003Csummary\u003EWrite tools are subject to the permission gate (approve-writes / read-only modes).\u003C/summary\u003E\r\n\tpublic bool Writes { get; init; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Optional requirement key (e.g. an integration\u0027s 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/// \u003C/summary\u003E\r\n\tpublic string Requires { get; init; }\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n/// Optional description for a tool parameter, surfaced in the JSON Schema.\r\n/// \u003C/summary\u003E\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n/// A discovered [McpTool] method, with its generated descriptor and an\r\n/// argument-binding invoker.\r\n/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n\t/// Why this tool cannot run right now (\u0022Disabled\u0022, \u0022Not Installed\u0022, ...),\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/// \u003C/summary\u003E\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 \u0022Disabled\u0022;\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 =\u003E 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 ) =\u003E\r\n\t\tmeta.Writes ? $\u0022{meta.Description} (modifies project state)\u0022 : meta.Description;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Binds JSON arguments to the method\u0027s parameters by name and invokes it.\r\n\t/// Throws ToolArgumentException on missing/unbindable arguments.\r\n\t/// \u003C/summary\u003E\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 \u003C parameters.Length; i\u002B\u002B )\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 \u0026\u0026 a.TryGetProperty( p.Name, out var value )\r\n\t\t\t\t\u0026\u0026 (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$\u0022Argument \u0027{p.Name}\u0027 could not be read as {p.ParameterType.Name}: {e.Message}\u0022, 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( $\u0022Missing required argument \u0027{p.Name}\u0027\u0022 );\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/// \u003Csummary\u003E\r\n/// Discovers [McpTool] static methods and serves them to the MCP server.\r\n/// \u003C/summary\u003E\r\npublic sealed class ToolRegistry\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// Maps a tool\u0027s Requires key to an unavailability reason (short, e.g.\r\n\t/// \u0022Not Installed\u0022) or null when the requirement is satisfied. Null\r\n\t/// resolver = everything available.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Func\u003Cstring, string\u003E RequirementResolver { get; set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Whether the user has disabled this tool. Null resolver = only\r\n\t/// DisabledByDefault applies.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Func\u003CRegisteredTool, bool\u003E 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\u003CRegisteredTool\u003E _tools = new();\r\n\treadonly Dictionary\u003Cstring, RegisteredTool\u003E _byName = new( StringComparer.Ordinal );\r\n\r\n\tpublic IReadOnlyList\u003CRegisteredTool\u003E Tools =\u003E _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 =\u003E t.IsClass )\r\n\t\t\t.SelectMany( t =\u003E t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )\r\n\t\t\t.Select( m =\u003E (Method: m, Meta: m.GetCustomAttribute\u003CMcpToolAttribute\u003E()) )\r\n\t\t\t.Where( x =\u003E x.Meta is not null )\r\n\t\t\t.OrderBy( x =\u003E 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 ) =\u003E _byName.GetValueOrDefault( name );\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n\t/// Converts a tool\u0027s return value to the text sent back to the client.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static string FormatResult( object result ) =\u003E result switch\r\n\t{\r\n\t\tnull =\u003E \u0022\u0022\u0022{ \u0022ok\u0022: true }\u0022\u0022\u0022,\r\n\t\tstring s =\u003E s,\r\n\t\t_ =\u003E 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( \u0022prefab_instantiate\u0022, \u0022Instantiates a prefab into the active scene.\u0022, ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object Instantiate(\r\n\t\t[Desc( \u0022Prefab asset path, e.g. \u0027prefabs/door.prefab\u0027\u0022 )] string prefabPath,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tvar prefabFile = ResourceLibrary.Get\u003CPrefabFile\u003E( prefabPath )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022No prefab at \u0027{prefabPath}\u0027 - use asset_search with assetType \u0027prefab\u0027\u0022 );\r\n\r\n\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022Prefab \u0027{prefabPath}\u0027 could not be loaded\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( $\u0022MCP: instantiate {prefabPath}\u0022 ).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, \u0022position\u0022 ) );\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( \u0022prefab_instantiate_many\u0022, \u0022Instantiates 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.\u0022, ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object InstantiateMany(\r\n\t\t[Desc( \u0022Prefab asset path, e.g. \u0027prefabs/tree.prefab\u0027\u0022 )] string prefabPath,\r\n\t\t[Desc( \u0022World positions, each [x, y, z]\u0022 )] float[][] positions )\r\n\t{\r\n\t\tif ( positions is null || positions.Length == 0 )\r\n\t\t\tthrow new ArgumentException( \u0022Pass at least one position\u0022 );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tvar prefabFile = ResourceLibrary.Get\u003CPrefabFile\u003E( prefabPath )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022No prefab at \u0027{prefabPath}\u0027 - use asset_search with assetType \u0027prefab\u0027\u0022 );\r\n\r\n\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022Prefab \u0027{prefabPath}\u0027 could not be loaded\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( $\u0022MCP: instantiate {positions.Length}x {prefabPath}\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar instances = new List\u003Cobject\u003E();\r\n\t\tforeach ( var pos in positions )\r\n\t\t{\r\n\t\t\tvar instance = prefabScene.Clone( new Transform( ToVector3( pos, \u0022position\u0022 ) ) );\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( \u0022prefab_create_from_gameobject\u0022, \u0022Turns a GameObject (and its children) into a reusable .prefab asset; the original becomes an instance of it.\u0022, ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object CreateFromGameObject(\r\n\t\t[Desc( \u0022GameObject id or unique name\u0022 )] string gameObject,\r\n\t\t[Desc( \u0022Output path ending in .prefab, e.g. \u0027prefabs/door.prefab\u0027\u0022 )] string prefabPath )\r\n\t{\r\n\t\tif ( !prefabPath.EndsWith( \u0022.prefab\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new ArgumentException( \u0022prefabPath must end in .prefab\u0022 );\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( $\u0022\u0027{prefabPath}\u0027 already exists\u0022 );\r\n\r\n\t\tSystem.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );\r\n\r\n\t\tusing var undo = session.UndoScope( $\u0022MCP: create prefab {prefabPath}\u0022 )\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( \u0022prefab_break_instance\u0022, \u0022Unlinks a prefab instance so it becomes plain GameObjects.\u0022, ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object BreakInstance( [Desc( \u0022GameObject id or unique name of the prefab instance root\u0022 )] 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( $\u0022\u0027{go.Name}\u0027 is not a prefab instance\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: break prefab instance\u0022 )\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( \u0022prefab_update_from_prefab\u0022, \u0022Re-syncs a prefab instance from its source prefab file.\u0022, ToolCategory.Prefab, Writes = true )]\r\n\tpublic static object UpdateFromPrefab( [Desc( \u0022GameObject id or unique name of the prefab instance root\u0022 )] 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( $\u0022\u0027{go.Name}\u0027 is not a prefab instance\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: update from prefab\u0022 )\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/// \u003Csummary\u003E\r\n/// Tools that operate on the MCP server itself: batch execution (many calls in\r\n/// one request) and reading/adjusting the server\u0027s own configuration.\r\n/// \u003C/summary\u003E\r\npublic static class ServerTools\r\n{\r\n\t[McpTool( \u0022batch\u0022, \u0022Runs 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.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object Batch(\r\n\t\t[Desc( \u0022JSON array of steps, e.g. [{\\\u0022name\\\u0022:\\\u0022gameobject_create\\\u0022,\\\u0022arguments\\\u0022:{\\\u0022name\\\u0022:\\\u0022X\\\u0022}}, ...]\u0022 )] JsonElement steps,\r\n\t\t[Desc( \u0022Keep going after a step fails instead of stopping\u0022 )] bool continueOnError = false )\r\n\t{\r\n\t\tif ( steps.ValueKind != JsonValueKind.Array )\r\n\t\t\tthrow new ArgumentException( \u0022steps must be a JSON array of {name, arguments} objects\u0022 );\r\n\r\n\t\tvar registry = McpHost.Registry\r\n\t\t\t?? throw new InvalidOperationException( \u0022Server not initialized\u0022 );\r\n\r\n\t\tvar results = new List\u003Cobject\u003E();\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\u002B\u002B;\r\n\r\n\t\t\tif ( !step.TryGetProperty( \u0022name\u0022, 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 = \u0022step is missing a string \u0027name\u0027\u0022 } );\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 ? \u0022unknown tool\u0022 : $\u0022unavailable: {tool.UnavailableReason}\u0022 } );\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( \u0022arguments\u0022, out var a ) \u0026\u0026 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\u0027t 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 = \u0022this tool is async and cannot run inside a batch - call it on its own\u0022 } );\r\n\t\t\t\t\tLogStep( tool, args, true, \u0022async tool skipped\u0022 );\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 =\u003E r.GetType().GetProperty( \u0022ok\u0022 )?.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 = $\u0022batch:{tool.Meta.Name}\u0022,\r\n\t\t\tCategory = tool.Meta.Category,\r\n\t\t\tArgsDigest = PermissionGate.Summarize( args ),\r\n\t\t\tOk = error is null \u0026\u0026 !skipped,\r\n\t\t\tError = error\r\n\t\t} );\r\n\t}\r\n\r\n\t[McpTool( \u0022server_get_config\u0022, \u0022Reads the MCP server\u0027s current configuration: port, permission mode, autostart, tool counts.\u0022, 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\u003CRegisteredTool\u003E)Array.Empty\u003CRegisteredTool\u003E();\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 =\u003E t.IsAvailable ),\r\n\t\t\tconnectedClients = McpHost.Server?.Sessions.Count ?? 0,\r\n\t\t\tnote = \u0022Permission mode is set by the user in the dashboard and cannot be changed over MCP by design.\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022server_set_config\u0022, \u0022Adjusts server settings the AI is allowed to change (port, autostart). Permission mode stays user-only. Changing the port restarts the listener.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object SetConfig(\r\n\t\t[Desc( \u0022New port 1024-65535; omit to leave unchanged\u0022 )] int? port = null,\r\n\t\t[Desc( \u0022Autostart on editor load; omit to leave unchanged\u0022 )] 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 \u003C 1024 or \u003E 65535 )\r\n\t\t\t\tthrow new ArgumentException( \u0022port must be 1024..65535\u0022 );\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 ? \u0022Listener restarted on the new port - reconnect your client.\u0022 : \u0022Updated.\u0022 };\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 = \u0022Import Tools From Library\u0022;\r\n\t\tWindow.SetWindowIcon( \u0022library_add\u0022 );\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\u0022Expose public static methods from installed libraries as MCP tools. \u0022\r\n\t\t\t\u002B \u0022Imported tools persist, re-bind every session, and are write-gated by approvals.\u0022, this ) );\r\n\t\thint.SetStyles( $\u0022color: {Theme.TextLight.Hex}; font-size: 11px;\u0022 );\r\n\t\thint.WordWrap = true;\r\n\r\n\t\t_search = Layout.Add( new LineEdit( this ) { PlaceholderText = \u0022Search methods, types or libraries...\u0022 } );\r\n\t\t_search.TextEdited \u002B= _ =\u003E 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( \u0022Done\u0022 ) { Icon = \u0022check\u0022 } );\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, \u0022Libraries\u0022, \u0022extension\u0022,\r\n\t\t\tcandidates.Where( ToolImporter.IsLibraryAssembly ).ToList(), query );\r\n\r\n\t\tAddSection( canvas, \u0022Project \u0026 Other\u0022, \u0022folder\u0022,\r\n\t\t\tcandidates.Where( a =\u003E !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\u003CAssembly\u003E assemblies, string query )\r\n\t{\r\n\t\tvar header = canvas.Layout.Add( new Label( title, canvas ) );\r\n\t\theader.SetStyles( $\u0022color: {Theme.Blue.Hex}; font-size: 12px; font-weight: 700; margin-top: 8px;\u0022 );\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 =\u003E 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( $\u0022color: {Theme.Text.Hex}; font-size: 11px; font-weight: 600; margin-top: 4px; margin-left: 6px;\u0022 );\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( \u0022, \u0022, method.GetParameters().Select( p =\u003E p.Name ) );\r\n\t\t\t\tvar check = canvas.Layout.Add( new Checkbox( $\u0022{method.DeclaringType?.Name}.{method.Name}({parameters})\u0022, 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 = () =\u003E\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 ) ? \u0022Nothing importable found.\u0022 : \u0022No matches.\u0022, canvas ) );\r\n\t\t\tempty.SetStyles( $\u0022color: {Theme.TextLight.Hex}; font-size: 11px; margin-left: 6px;\u0022 );\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/// \u003Csummary\u003E\r\n/// Searchable, category-filterable browser of every tool the server exposes.\r\n/// Doubles as documentation.\r\n/// \u003C/summary\u003E\r\npublic class ToolsPage : Widget\r\n{\r\n\treadonly LineEdit _search;\r\n\treadonly List\u003CCategoryChip\u003E _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 = \u0022Search tools...\u0022 }, 1 );\r\n\t\t_search.TextEdited \u002B= _ =\u003E Rebuild();\r\n\r\n\t\tvar import = searchRow.Add( new Button( \u0022Import Tools\u0022, \u0022library_add\u0022 ) );\r\n\t\timport.ToolTip = \u0022Expose public static methods from other installed libraries as MCP tools\u0022;\r\n\t\timport.Clicked = () =\u003E 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\u003CToolCategory\u003E() )\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u002B tools.Count( t =\u003E 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 =\u003E c.Toggled ).Select( c =\u003E c.Category ).ToHashSet();\r\n\r\n\t\tvar tools = (McpHost.Registry?.Tools ?? (IReadOnlyList\u003CRegisteredTool\u003E)Array.Empty\u003CRegisteredTool\u003E())\r\n\t\t\t.Where( t =\u003E enabled.Contains( t.Meta.Category ) )\r\n\t\t\t.Where( t =\u003E 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( $\u0022{tools.Count} tools\u0022, canvas ) );\r\n\t\tcount.SetStyles( $\u0022color: {Palette.TextDim.Hex}; font-size: 10px;\u0022 );\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/// \u003Csummary\u003E\r\n/// One tool entry: name (mono), write badge, wrapped description.\r\n/// \u003C/summary\u003E\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 \u002B \u0022\\n\\nClick the toggle to enable/disable this tool.\u0022;\r\n\t}\r\n\r\n\tbool UserDisabled =\u003E 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 \u003C 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 \u0026\u0026 !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 \u002B 2, LocalRect.Top \u002B 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( \u0022Consolas\u0022, 8, 600 );\r\n\t\tvar nameWidth = Paint.MeasureText( _tool.Meta.Name ).x;\r\n\t\tPaint.DrawText( new Rect( LocalRect.Left \u002B 14, LocalRect.Top \u002B 4, nameWidth \u002B 4, 14 ), _tool.Meta.Name, TextFlag.LeftCenter );\r\n\r\n\t\tvar badgeLeft = LocalRect.Left \u002B 20 \u002B nameWidth;\r\n\r\n\t\t// writes badge\r\n\t\tif ( _tool.Meta.Writes \u0026\u0026 !disabled )\r\n\t\t{\r\n\t\t\tvar badge = new Rect( badgeLeft, LocalRect.Top \u002B 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, \u0022WRITES\u0022, TextFlag.Center );\r\n\t\t}\r\n\r\n\t\t// unavailable badge, e.g. \u0022Not Installed\u0022\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 \u002B 12;\r\n\t\t\tvar badge = new Rect( badgeLeft, LocalRect.Top \u002B 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 \u002B 14, LocalRect.Top \u002B 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 ? \u0022toggle_off\u0022 : \u0022toggle_on\u0022, 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/// \u003Csummary\u003E\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/// \u0022since\u0022 cursor instead of re-reading old entries.\r\n/// \u003C/summary\u003E\r\npublic static class LogCapture\r\n{\r\n\tconst int Capacity = 4000;\r\n\r\n\tstatic readonly LinkedList\u003CCapturedLog\u003E _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/// \u003Csummary\u003EThe sequence number of the newest captured entry (0 if none).\r\n\t/// Pass it back as \u0060sinceSeq\u0060 next call to get only what\u0027s new.\u003C/summary\u003E\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 = \u002B\u002B_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 \u003E Capacity )\r\n\t\t\t\t_logs.RemoveLast();\r\n\t\t}\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ENewest-first recent entries, optionally only those newer than\r\n\t/// \u003Cparamref name=\u0022sinceSeq\u0022/\u003E (the incremental cursor).\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CCapturedLog\u003E 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 =\u003E l.Seq \u003E sinceSeq )\r\n\t\t\t\t.Where( l =\u003E Rank( l.Level ) \u003E= threshold )\r\n\t\t\t\t.Where( l =\u003E !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/// \u003Csummary\u003ERegex/severity/time-filtered search over the buffer.\u003C/summary\u003E\r\n\tpublic static IReadOnlyList\u003CCapturedLog\u003E 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 =\u003E Rank( l.Level ) \u003E= threshold )\r\n\t\t\t\t.Where( l =\u003E since is null || l.Time \u003E= since.Value )\r\n\t\t\t\t.Where( l =\u003E rx is null || (l.Message is not null \u0026\u0026 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 ) =\u003E level?.ToLowerInvariant() switch\r\n\t{\r\n\t\t\u0022error\u0022 =\u003E 4,\r\n\t\t\u0022warn\u0022 or \u0022warning\u0022 =\u003E 3,\r\n\t\t\u0022info\u0022 =\u003E 2,\r\n\t\t\u0022debug\u0022 or \u0022trace\u0022 =\u003E 1,\r\n\t\t_ =\u003E 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/// \u003Csummary\u003E\r\n/// Reflects a tool method\u0027s parameters into a JSON Schema object.\r\n/// \u003C/summary\u003E\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\u003CDescAttribute\u003E()?.Text;\r\n\t\t\tif ( desc is not null )\r\n\t\t\t\tprop[\u0022description\u0022] = 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[\u0022default\u0022] = 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[\u0022type\u0022] = \u0022object\u0022,\r\n\t\t\t[\u0022properties\u0022] = properties\r\n\t\t};\r\n\r\n\t\tif ( required.Count \u003E 0 )\r\n\t\t\tschema[\u0022required\u0022] = 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 { [\u0022type\u0022] = \u0022string\u0022 };\r\n\r\n\t\tif ( t == typeof( bool ) )\r\n\t\t\treturn new JsonObject { [\u0022type\u0022] = \u0022boolean\u0022 };\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 { [\u0022type\u0022] = \u0022integer\u0022 };\r\n\r\n\t\tif ( t == typeof( float ) || t == typeof( double ) || t == typeof( decimal ) )\r\n\t\t\treturn new JsonObject { [\u0022type\u0022] = \u0022number\u0022 };\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 { [\u0022type\u0022] = \u0022string\u0022, [\u0022enum\u0022] = values };\r\n\t\t}\r\n\r\n\t\tif ( t.IsArray )\r\n\t\t\treturn new JsonObject { [\u0022type\u0022] = \u0022array\u0022, [\u0022items\u0022] = ForType( t.GetElementType() ) };\r\n\r\n\t\tif ( t.IsGenericType \u0026\u0026 typeof( IEnumerable ).IsAssignableFrom( t ) )\r\n\t\t\treturn new JsonObject { [\u0022type\u0022] = \u0022array\u0022, [\u0022items\u0022] = 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 { [\u0022type\u0022] = \u0022object\u0022 };\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/// \u003Csummary\u003E\r\n/// A tool as advertised to MCP clients via tools/list.\r\n/// \u003C/summary\u003E\r\npublic record McpToolDescriptor( string Name, string Description, JsonElement InputSchema );\r\n\r\n/// \u003Csummary\u003E\r\n/// Result payload shapes defined by the MCP specification.\r\n/// \u003C/summary\u003E\r\npublic static class McpResults\r\n{\r\n\tpublic const string ServerName = \u0022sbox-mcp\u0022;\r\n\tpublic const string ServerVersion = \u00221.0.0\u0022;\r\n\r\n\tpublic static object Initialize( string negotiatedVersion ) =\u003E 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\u003CMcpToolDescriptor\u003E tools ) =\u003E 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 ) =\u003E new\r\n\t{\r\n\t\tcontent = new object[] { new { type = \u0022text\u0022, 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\u003Cobject\u003E { new { type = \u0022image\u0022, data = base64Png, mimeType = \u0022image/png\u0022 } };\r\n\t\tif ( !string.IsNullOrEmpty( text ) )\r\n\t\t\tcontent.Add( new { type = \u0022text\u0022, 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\tpublic static readonly string[] Supported = { \u00222025-06-18\u0022 };\r\n\r\n\t/// \u003Csummary\u003EExact match wins; anything else gets our newest revision.\u003C/summary\u003E\r\n\tpublic static string Negotiate( string clientRequested ) =\u003E\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/// \u003Csummary\u003E\r\n/// Confines file access to the project root. Every file-touching tool resolves\r\n/// paths through here.\r\n/// \u003C/summary\u003E\r\npublic static class PathJail\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// Resolves \u003Cparamref name=\u0022path\u0022/\u003E (relative to root, or absolute) and\r\n\t/// throws if it escapes \u003Cparamref name=\u0022root\u0022/\u003E.\r\n\t/// \u003C/summary\u003E\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( \u0022Path must not be empty\u0022 );\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\u0026\u0026 !full.StartsWith( rootFull \u002B Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t{\r\n\t\t\tthrow new UnauthorizedAccessException( $\u0022Path \u0027{path}\u0027 is outside the project and cannot be accessed\u0022 );\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/// \u003Csummary\u003E\r\n/// High-value concrete tools on top of the universal mechanisms: tags,\r\n/// bounds, orientation, bulk creation, component copy.\r\n/// \u003C/summary\u003E\r\npublic static class ExtraTools\r\n{\r\n\t[McpTool( \u0022gameobject_add_tag\u0022, \u0022Adds a tag to a GameObject (tags drive collision filtering, queries and gameplay logic).\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object AddTag( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: add tag {tag}\u0022 ).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( \u0022gameobject_remove_tag\u0022, \u0022Removes a tag from a GameObject.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object RemoveTag( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: remove tag {tag}\u0022 ).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( \u0022gameobject_get_bounds\u0022, \u0022Gets a GameObject\u0027s world-space bounding box (renderers \u002B children).\u0022, ToolCategory.GameObject )]\r\n\tpublic static object GetBounds( [Desc( \u0022GameObject id or unique name\u0022 )] 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( \u0022gameobject_look_at\u0022, \u0022Rotates a GameObject to face a target position or another GameObject.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object LookAt(\r\n\t\t[Desc( \u0022GameObject id or unique name to rotate\u0022 )] string gameObject,\r\n\t\t[Desc( \u0022Target world position [x, y, z]; ignored when targetObject is set\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Target GameObject id/name to face\u0022 )] 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, \u0022position\u0022 )\r\n\t\t\t: throw new ArgumentException( \u0022Pass either position or targetObject\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: look at\u0022 ).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( \u0022gameobject_create_many\u0022, \u0022Creates several GameObjects at once (e.g. a grid or row). Returns their ids.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object CreateMany(\r\n\t\t[Desc( \u0022Base name; each gets a numeric suffix\u0022 )] string name,\r\n\t\t[Desc( \u0022How many to create\u0022 )] int count,\r\n\t\t[Desc( \u0022Position of the first [x, y, z]\u0022 )] float[] startPosition = null,\r\n\t\t[Desc( \u0022Offset added per object [x, y, z]\u0022 )] float[] step = null,\r\n\t\t[Desc( \u0022Parent id; omit for scene root\u0022 )] string parentId = null )\r\n\t{\r\n\t\tif ( count is \u003C 1 or \u003E 512 )\r\n\t\t\tthrow new ArgumentException( \u0022count must be 1..512\u0022 );\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, \u0022startPosition\u0022 );\r\n\t\tvar delta = step is null ? new Vector3( 60, 0, 0 ) : ToVector3( step, \u0022step\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( $\u0022MCP: create {count} objects\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar created = new object[count];\r\n\t\tfor ( var i = 0; i \u003C count; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar go = session.Scene.CreateObject();\r\n\t\t\tgo.Name = $\u0022{name} {i \u002B 1}\u0022;\r\n\t\t\tif ( parent is not null ) go.Parent = parent;\r\n\t\t\tgo.WorldPosition = start \u002B 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( \u0022component_copy\u0022, \u0022Copies all property values from one component to another GameObject\u0027s component of the same type (e.g. clone a configured renderer\u0027s settings). Creates the component on the target if it doesn\u0027t have one yet.\u0022, ToolCategory.Component, Writes = true )]\r\n\tpublic static object CopyComponent(\r\n\t\t[Desc( \u0022Source GameObject id or unique name\u0022 )] string fromGameObject,\r\n\t\t[Desc( \u0022Target GameObject id or unique name\u0022 )] string toGameObject,\r\n\t\t[Desc( \u0022Component type name\u0022 )] 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\u003CComponent\u003E( FindMode.EverythingInSelf )\r\n\t\t\t.FirstOrDefault( c =\u003E c.GetType() == source.GetType() );\r\n\r\n\t\tusing var undo = session.UndoScope( $\u0022MCP: copy {type}\u0022 )\r\n\t\t\t.WithComponentCreations()\r\n\t\t\t.WithComponentChanges( existing is not null ? new[] { existing } : Array.Empty\u003CComponent\u003E() )\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( $\u0022Could not create a {type} on \u0027{toGameObject}\u0027\u0022 );\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\u0027s own identity; copy only the values\r\n\t\t\tnode.Remove( \u0022__guid\u0022 );\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( \u0022gameobject_create\u0022, \u0022Creates a new GameObject in the active scene.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Create(\r\n\t\tstring name,\r\n\t\t[Desc( \u0022Id of the parent GameObject; omit for scene root\u0022 )] string parentId = null,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Rotation [pitch, yaw, roll] in degrees\u0022 )] float[] rotation = null,\r\n\t\t[Desc( \u0022Scale [x, y, z]\u0022 )] 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( $\u0022MCP: create {name}\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? \u0022GameObject\u0022 : 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, \u0022position\u0022 );\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( \u0022\u0027rotation\u0027 must be [pitch, yaw, roll]\u0022 );\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, \u0022scale\u0022 );\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \u0022gameobject_spawn_model\u0022, \u0022Spawns 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 \u0027place a model\u0027 operation (vs gameobject_create \u002B component_add \u002B component_set_property). Note: withCollider uses the model\u0027s own collision mesh - dev primitives like box.vmdl have none, so add a BoxCollider yourself for those.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnModel(\r\n\t\t[Desc( \u0022Model asset path, e.g. \u0027models/dev/box.vmdl\u0027\u0022 )] string model,\r\n\t\t[Desc( \u0022Object name; defaults to the model\u0027s file name\u0022 )] string name = null,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Also add a ModelCollider (only solid if the model has a collision mesh)\u0022 )] bool withCollider = false )\r\n\t{\r\n\t\tif ( AssetSystem.FindByPath( model ) is null )\r\n\t\t\tthrow new InvalidOperationException( $\u0022No model at \u0027{model}\u0027 - use asset_search with assetType \u0027model\u0027\u0022 );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: spawn model\u0022 ).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, \u0022position\u0022 );\r\n\r\n\t\tvar loaded = Model.Load( model );\r\n\t\tgo.Components.Create\u003CModelRenderer\u003E().Model = loaded;\r\n\r\n\t\tif ( withCollider )\r\n\t\t\tgo.Components.Create\u003CModelCollider\u003E().Model = loaded;\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \u0022gameobject_spawn_light\u0022, \u0022Spawns 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.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnLight(\r\n\t\t[Desc( \u0022Light type: \u0027point\u0027, \u0027spot\u0027, or \u0027directional\u0027\u0022 )] string lightType = \u0022point\u0022,\r\n\t\t[Desc( \u0022Object name; defaults to the light type\u0022 )] string name = null,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Rotation [pitch, yaw, roll] - aims spot/directional lights\u0022 )] float[] rotation = null,\r\n\t\t[Desc( \u0022Light color [r, g, b] (0-1); omit for white\u0022 )] float[] color = null )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: spawn light\u0022 ).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, \u0022position\u0022 );\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( \u0022\u0027rotation\u0027 must be [pitch, yaw, roll]\u0022 );\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 ?? \u0022point\u0022).ToLowerInvariant() switch\r\n\t\t{\r\n\t\t\t\u0022point\u0022 or \u0022\u0022 =\u003E go.Components.Create\u003CPointLight\u003E(),\r\n\t\t\t\u0022spot\u0022 =\u003E go.Components.Create\u003CSpotLight\u003E(),\r\n\t\t\t\u0022directional\u0022 or \u0022sun\u0022 or \u0022dir\u0022 =\u003E go.Components.Create\u003CDirectionalLight\u003E(),\r\n\t\t\t_ =\u003E throw new ArgumentException( \u0022lightType must be \u0027point\u0027, \u0027spot\u0027 or \u0027directional\u0027\u0022 )\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( \u0022\u0027color\u0027 must be [r, g, b] or [r, g, b, a]\u0022 );\r\n\r\n\t\t\tlight.LightColor = new Color( color[0], color[1], color[2], color.Length \u003E 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( \u0022gameobject_spawn_camera\u0022, \u0022Spawns 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.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SpawnCamera(\r\n\t\t[Desc( \u0022Object name\u0022 )] string name = \u0022Camera\u0022,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Rotation [pitch, yaw, roll] - where the camera looks\u0022 )] float[] rotation = null,\r\n\t\t[Desc( \u0022Field of view in degrees (default 60)\u0022 )] float fieldOfView = 60f )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: spawn camera\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( name ) ? \u0022Camera\u0022 : name;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \u0022position\u0022 );\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( \u0022\u0027rotation\u0027 must be [pitch, yaw, roll]\u0022 );\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\u003CCameraComponent\u003E().FieldOfView = fieldOfView;\r\n\r\n\t\treturn Describe( go );\r\n\t}\r\n\r\n\t[McpTool( \u0022gameobject_delete\u0022, \u0022Deletes a GameObject (and its children).\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Delete( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: delete {name}\u0022 )\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( \u0022gameobject_rename\u0022, \u0022Renames a GameObject.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Rename( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: rename to {newName}\u0022 )\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( \u0022gameobject_set_enabled\u0022, \u0022Enables or disables a GameObject.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetEnabled( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: set enabled {enabled}\u0022 )\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( \u0022gameobject_set_parent\u0022, \u0022Reparents a GameObject (keeps world position).\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetParent(\r\n\t\t[Desc( \u0022GameObject id or unique name\u0022 )] string gameObject,\r\n\t\t[Desc( \u0022New parent id; omit to move to scene root\u0022 )] 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( \u0022MCP: reparent\u0022 )\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( \u0022gameobject_get_transform\u0022, \u0022Gets a GameObject\u0027s world and local transform.\u0022, ToolCategory.GameObject )]\r\n\tpublic static object GetTransform( [Desc( \u0022GameObject id or unique name\u0022 )] 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( \u0022gameobject_set_transform\u0022, \u0022Sets position/rotation/scale on a GameObject. Omitted parts stay unchanged.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object SetTransform(\r\n\t\t[Desc( \u0022GameObject id or unique name\u0022 )] string gameObject,\r\n\t\t[Desc( \u0022Position [x, y, z]\u0022 )] float[] position = null,\r\n\t\t[Desc( \u0022Rotation [pitch, yaw, roll] in degrees\u0022 )] float[] rotation = null,\r\n\t\t[Desc( \u0022Scale [x, y, z]\u0022 )] float[] scale = null,\r\n\t\t[Desc( \u0022Apply in world space instead of local space\u0022 )] 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( \u0022MCP: set transform\u0022 )\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, \u0022position\u0022 );\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( \u0022\u0027rotation\u0027 must be [pitch, yaw, roll]\u0022 );\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, \u0022scale\u0022 );\r\n\r\n\t\treturn GetTransform( go.Id.ToString() );\r\n\t}\r\n\r\n\t[McpTool( \u0022gameobject_duplicate\u0022, \u0022Duplicates a GameObject next to the original.\u0022, ToolCategory.GameObject, Writes = true )]\r\n\tpublic static object Duplicate( [Desc( \u0022GameObject id or unique name\u0022 )] 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( $\u0022MCP: duplicate {go.Name}\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar clone = go.Clone( go.WorldTransform, go.Parent, go.Enabled, $\u0022{go.Name} (copy)\u0022 );\r\n\t\treturn Describe( clone );\r\n\t}\r\n\r\n\t[McpTool( \u0022gameobject_find\u0022, \u0022Searches GameObjects by name substring, component type, and/or tag.\u0022, ToolCategory.GameObject )]\r\n\tpublic static object Find(\r\n\t\t[Desc( \u0022Name substring (case-insensitive); omit to match all\u0022 )] string query = null,\r\n\t\t[Desc( \u0022Only objects having this component type\u0022 )] string componentType = null,\r\n\t\t[Desc( \u0022Only objects carrying this tag\u0022 )] 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 =\u003E o is not Scene )\r\n\t\t\t.Where( o =\u003E query is null || o.Name.Contains( query, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Where( o =\u003E tag is null || o.Tags.Has( tag ) )\r\n\t\t\t.Where( o =\u003E componentType is null || o.Components.GetAll\u003CComponent\u003E( FindMode.EverythingInSelf )\r\n\t\t\t\t.Any( c =\u003E 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( \u0022gameobject_get_details\u0022, \u0022Gets a GameObject with all component properties as JSON.\u0022, ToolCategory.GameObject )]\r\n\tpublic static object GetDetails( [Desc( \u0022GameObject id or unique name\u0022 )] 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\u003CComponent\u003E( FindMode.EverythingInSelf )\r\n\t\t\t\t.Select( c =\u003E 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( \u0022gameobject_select\u0022, \u0022Selects GameObjects in the editor (replaces current selection).\u0022, ToolCategory.GameObject )]\r\n\tpublic static object Select( [Desc( \u0022GameObject ids or unique names\u0022 )] 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 =\u003E 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/// \u003Csummary\u003E\r\n/// Top-level \u0022MCP\u0022 menu in the editor menu bar (lands next to Help).\r\n/// \u003C/summary\u003E\r\npublic static class McpMenu\r\n{\r\n\t[Menu( \u0022Editor\u0022, \u0022MCP/Open Dashboard\u0022, \u0022hub\u0022 )]\r\n\tpublic static void OpenDashboard() =\u003E McpDock.Open();\r\n\r\n\t[Menu( \u0022Editor\u0022, \u0022MCP/Start Server\u0022, \u0022play_arrow\u0022 )]\r\n\tpublic static void StartServer() =\u003E McpHost.Start();\r\n\r\n\t[Menu( \u0022Editor\u0022, \u0022MCP/Stop Server\u0022, \u0022stop\u0022 )]\r\n\tpublic static void StopServer() =\u003E McpHost.Stop();\r\n}\r\n\r\n/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\npublic class McpDock : Widget\r\n{\r\n\tstatic McpDock _instance;\r\n\r\n\t/// \u003Csummary\u003EThe open dashboard instance, if any.\u003C/summary\u003E\r\n\tpublic static McpDock Instance =\u003E _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() =\u003E 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\u0027s 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 \u003E 1f \u0026\u0026 Width \u003E 100 \u0026\u0026 Height \u003E 100 )\r\n\t\t\tMcpSettings.DockSize = Size;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EOpens (or raises) the dashboard.\u003C/summary\u003E\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\u0026box 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( \u0022MCP\u0022, \u0022hub\u0022, 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 = \u0022McpDock\u0022;\r\n\t\tWindowTitle = \u0022MCP\u0022;\r\n\t\tSetWindowIcon( \u0022hub\u0022 );\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( \u0022Overview\u0022, \u0022dashboard\u0022, this ),\r\n\t\t\tnew TabButton( \u0022Activity\u0022, \u0022bolt\u0022, this ),\r\n\t\t\tnew TabButton( \u0022Tools\u0022, \u0022construction\u0022, this ),\r\n\t\t\tnew TabButton( \u0022Settings\u0022, \u0022tune\u0022, this )\r\n\t\t};\r\n\r\n\t\tfor ( var i = 0; i \u003C _tabs.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar index = i;\r\n\t\t\t_tabs[i].Clicked = () =\u003E 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 \u003C _pages.Length; i\u002B\u002B )\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 ? \u0022stopped\u0022 : \u0022error\u0022)\r\n\t\t\t: sessions \u003E 0 ? $\u0022running \u00B7 {sessions} client{(sessions == 1 ? \u0022\u0022 : \u0022s\u0022)}\u0022 : \u0022running\u0022;\r\n\t\t_header.Pulse = running ? (MathF.Sin( RealTime.Now * 3f ) \u002B 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/// \u003Csummary\u003E\r\n/// Tiny MCP indicator in the editor\u0027s status bar: a status dot, the label and\r\n/// the connected-client count. Click to open the dashboard.\r\n/// \u003C/summary\u003E\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 = \u0022s\u0026box MCP - click to open the dashboard\u0022;\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 \u002B 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 \u002B 17, LocalRect.Top, LocalRect.Width - 19, LocalRect.Height ),\r\n\t\t\trunning \u0026\u0026 sessions \u003E 0 ? $\u0022MCP \u00B7 {sessions}\u0022 : \u0022MCP\u0022, 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 = $\u0022{server?.IsRunning}|{server?.Sessions.Count}|{McpHost.LastError is not null}\u0022;\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/// \u003Csummary\u003EA 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.\u003C/summary\u003E\r\npublic sealed record ImportedToolDef( string Assembly, string Type, string Method, string Signature = null );\r\n\r\n/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003ETrue 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.\u003C/summary\u003E\r\n\tpublic static bool IsPortFromEnv =\u003E _portFromEnv;\r\n\r\n\t/// \u003Csummary\u003ECalled once from the editor main thread before anything reads settings.\u003C/summary\u003E\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 ... -\u003E binds 9191, cookie untouched\r\n\t\tvar env = Environment.GetEnvironmentVariable( \u0022SBOX_MCP_PORT\u0022 );\r\n\t\tif ( int.TryParse( env, out var envPort ) \u0026\u0026 envPort is \u003E 0 and \u003C 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( \u0022SboxMcp.Port\u0022, DefaultPort );\r\n\t\t}\r\n\r\n\t\t_autoStart = EditorCookie.Get( \u0022SboxMcp.AutoStart\u0022, true );\r\n\t\t_mode = EditorCookie.Get( \u0022SboxMcp.PermissionMode\u0022, PermissionMode.FullAccess );\r\n\t\tLoadExtras();\r\n\t}\r\n\r\n\tpublic static int Port\r\n\t{\r\n\t\tget =\u003E _port;\r\n\t\t// don\u0027t clobber the shared cookie when an env override is driving the port\r\n\t\tset { _port = value; if ( !_portFromEnv ) EditorCookie.Set( \u0022SboxMcp.Port\u0022, value ); }\r\n\t}\r\n\r\n\tpublic static bool AutoStart\r\n\t{\r\n\t\tget =\u003E _autoStart;\r\n\t\tset { _autoStart = value; EditorCookie.Set( \u0022SboxMcp.AutoStart\u0022, value ); }\r\n\t}\r\n\r\n\tpublic static PermissionMode Mode\r\n\t{\r\n\t\tget =\u003E _mode;\r\n\t\tset { _mode = value; EditorCookie.Set( \u0022SboxMcp.PermissionMode\u0022, 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 =\u003E _dockSize;\r\n\t\tset\r\n\t\t{\r\n\t\t\t_dockSize = value;\r\n\t\t\tEditorCookie.Set( \u0022SboxMcp.DockSize\u0022, $\u0022{(int)value.x}x{(int)value.y}\u0022 );\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 \u0022use the tool\u0027s default\u0022\r\n\tstatic Dictionary\u003Cstring, bool\u003E _toolDisabledOverrides = new();\r\n\r\n\t/// \u003Csummary\u003EThe user\u0027s explicit choice for a tool, or null = tool default.\u003C/summary\u003E\r\n\tpublic static bool? GetToolDisabledOverride( string toolName ) =\u003E\r\n\t\t_toolDisabledOverrides.TryGetValue( toolName, out var disabled ) ? disabled : null;\r\n\r\n\t/// \u003Csummary\u003EUI/main thread only (writes a cookie).\u003C/summary\u003E\r\n\tpublic static void SetToolDisabled( string toolName, bool disabled )\r\n\t{\r\n\t\tvar next = new Dictionary\u003Cstring, bool\u003E( _toolDisabledOverrides ) { [toolName] = disabled };\r\n\t\t_toolDisabledOverrides = next;\r\n\t\tEditorCookie.Set( \u0022SboxMcp.ToolOverrides\u0022,\r\n\t\t\tstring.Join( \u0022;\u0022, next.Select( kv =\u003E $\u0022{kv.Key}={(kv.Value ? 1 : 0)}\u0022 ) ) );\r\n\t}\r\n\r\n\t// ---- imported tools (persisted) ----------------------------------------\r\n\r\n\tstatic List\u003CImportedToolDef\u003E _importedTools = new();\r\n\r\n\tpublic static IReadOnlyList\u003CImportedToolDef\u003E ImportedTools =\u003E _importedTools;\r\n\r\n\t/// \u003Csummary\u003EUI/main thread only (writes a cookie).\u003C/summary\u003E\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\u003CImportedToolDef\u003E( _importedTools ) { def };\r\n\t\tSaveImports();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EUI/main thread only (writes a cookie).\u003C/summary\u003E\r\n\tpublic static void RemoveImportedTool( ImportedToolDef def )\r\n\t{\r\n\t\t_importedTools = _importedTools.Where( d =\u003E d != def ).ToList();\r\n\t\tSaveImports();\r\n\t}\r\n\r\n\tstatic void SaveImports() =\u003E\r\n\t\tEditorCookie.Set( \u0022SboxMcp.ImportedTools\u0022, JsonSerializer.Serialize( _importedTools ) );\r\n\r\n\tstatic void LoadExtras()\r\n\t{\r\n\t\tvar size = EditorCookie.Get( \u0022SboxMcp.DockSize\u0022, \u0022\u0022 );\r\n\t\tvar sizeParts = size.Split( \u0027x\u0027 );\r\n\t\tif ( sizeParts.Length == 2 \u0026\u0026 int.TryParse( sizeParts[0], out var w ) \u0026\u0026 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( \u0022SboxMcp.ToolOverrides\u0022, \u0022\u0022 );\r\n\t\t_toolDisabledOverrides = overrides\r\n\t\t\t.Split( \u0027;\u0027, StringSplitOptions.RemoveEmptyEntries )\r\n\t\t\t.Select( pair =\u003E pair.Split( \u0027=\u0027 ) )\r\n\t\t\t.Where( parts =\u003E parts.Length == 2 )\r\n\t\t\t.ToDictionary( parts =\u003E parts[0], parts =\u003E parts[1] == \u00221\u0022 );\r\n\r\n\t\tvar imports = EditorCookie.Get( \u0022SboxMcp.ImportedTools\u0022, \u0022\u0022 );\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\u003CImportedToolDef\u003E()\r\n\t\t\t\t: JsonSerializer.Deserialize\u003CList\u003CImportedToolDef\u003E\u003E( imports ) ?? new List\u003CImportedToolDef\u003E();\r\n\t\t}\r\n\t\tcatch ( JsonException )\r\n\t\t{\r\n\t\t\t_importedTools = new List\u003CImportedToolDef\u003E();\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/// \u003Csummary\u003E\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\u0027t register until\r\n/// it returns.\r\n/// \u003C/summary\u003E\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\u0022System\u0022, \u0022Microsoft\u0022, \u0022netstandard\u0022, \u0022mscorlib\u0022, \u0022Sandbox\u0022, \u0022Facepunch\u0022,\r\n\t\t\u0022NLog\u0022, \u0022Sentry\u0022, \u0022Refit\u0022, \u0022protobuf\u0022, \u0022Mono\u0022, \u0022MonoMod\u0022, \u0022Skia\u0022, \u0022Topten\u0022,\r\n\t\t\u0022Humanizer\u0022, \u0022Azure\u0022, \u0022LiteDB\u0022, \u0022Fleck\u0022, \u0022Zio\u0022, \u0022ExCSS\u0022, \u0022xunit\u0022, \u0022JetBrains\u0022\r\n\t};\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// True for assemblies compiled from installed s\u0026box libraries (named\r\n\t/// \u0022package.{org}.{ident}[.editor]\u0022), excluding the open project\u0027s own code.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static bool IsLibraryAssembly( Assembly assembly )\r\n\t{\r\n\t\tvar name = assembly.GetName().Name ?? \u0022\u0022;\r\n\t\tif ( !name.StartsWith( \u0022package.\u0022, 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( $\u0022package.{config.Org}.{config.Ident}\u0022, 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 ?? \u0022?\u0022;\r\n\t\treturn name.StartsWith( \u0022package.\u0022, StringComparison.OrdinalIgnoreCase ) ? name[8..] : name;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003ELoaded assemblies that look like user libraries with importable methods.\u003C/summary\u003E\r\n\tpublic static IEnumerable\u003CAssembly\u003E 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 =\u003E !a.IsDynamic \u0026\u0026 a != own )\r\n\t\t\t.Where( a =\u003E\r\n\t\t\t{\r\n\t\t\t\tvar name = a.GetName().Name ?? \u0022\u0022;\r\n\t\t\t\treturn name.Length \u003E 0 \u0026\u0026 !ExcludedPrefixes.Any( p =\u003E name.StartsWith( p, StringComparison.OrdinalIgnoreCase ) );\r\n\t\t\t} )\r\n\t\t\t.Where( a =\u003E CandidateMethods( a ).Any() )\r\n\t\t\t.OrderBy( a =\u003E a.GetName().Name );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EPublic static methods with simple, schema-expressible parameters.\u003C/summary\u003E\r\n\tpublic static IEnumerable\u003CMethodInfo\u003E 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 =\u003E t.IsClass \u0026\u0026 !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 =\u003E 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( \u0027.\u0027 ).Last();\r\n\t\t// include a short signature suffix so overloads and same-named types\r\n\t\t// don\u0027t collide on one tool name\r\n\t\tvar suffix = string.IsNullOrEmpty( def.Signature ) ? \u0022\u0022 : \u0022_\u0022 \u002B Math.Abs( def.Signature.GetHashCode() % 10000 );\r\n\t\treturn Sanitize( $\u0022lib_{typeName}_{def.Method}{suffix}\u0022 );\r\n\t}\r\n\r\n\tstatic string Sanitize( string name ) =\u003E\r\n\t\tnew( name.Select( c =\u003E char.IsLetterOrDigit( c ) ? char.ToLowerInvariant( c ) : \u0027_\u0027 ).ToArray() );\r\n\r\n\tstatic string SignatureOf( MethodInfo method ) =\u003E\r\n\t\tstring.Join( \u0022,\u0022, method.GetParameters().Select( p =\u003E p.ParameterType.Name ) );\r\n\r\n\tpublic static bool IsImported( MethodInfo method ) =\u003E\r\n\t\tMcpSettings.ImportedTools.Contains( DefFor( method ) );\r\n\r\n\tpublic static ImportedToolDef DefFor( MethodInfo method ) =\u003E\r\n\t\tnew( method.DeclaringType?.Assembly.GetName().Name, method.DeclaringType?.FullName, method.Name, SignatureOf( method ) );\r\n\r\n\t/// \u003Csummary\u003EImports a method now and persists the choice.\u003C/summary\u003E\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/// \u003Csummary\u003ERemoves an import now and persists the choice.\u003C/summary\u003E\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/// \u003Csummary\u003ERe-binds every persisted import that still resolves.\u003C/summary\u003E\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( $\u0022Imported tool {def.Type}.{def.Method} not found ({def.Assembly} missing?) - it will return when the library does\u0022 );\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 =\u003E 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 =\u003E m.Name == def.Method \u0026\u0026 !m.IsGenericMethodDefinition )\r\n\t\t\t.ToArray() ?? Array.Empty\u003CMethodInfo\u003E();\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 =\u003E 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( \u0022, \u0022, method.GetParameters().Select( p =\u003E p.Name ) );\r\n\t\tvar registered = registry.AddImported(\r\n\t\t\tToolNameFor( def ),\r\n\t\t\t$\u0022Imported from the \u0027{def.Assembly}\u0027 library: {def.Type.Split( \u0027.\u0027 ).Last()}.{def.Method}({parameters})\u0022,\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( $\u0022Could not import {def.Type}.{def.Method} - a tool named \u0027{ToolNameFor( def )}\u0027 already exists\u0022 );\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 = { \u0022\\\\obj\\\\\u0022, \u0022\\\\bin\\\\\u0022, \u0022/obj/\u0022, \u0022/bin/\u0022 };\r\n\tstatic readonly string[] SourceExtensions = { \u0022.cs\u0022, \u0022.razor\u0022, \u0022.scss\u0022, \u0022.shader\u0022, \u0022.hlsl\u0022 };\r\n\r\n\t/// \u003Csummary\u003ESkip build output and any dot-directory (.git, .sbox, .removed-libraries...).\u003C/summary\u003E\r\n\tstatic bool IsSkipped( string fullPath )\r\n\t{\r\n\t\tif ( SkippedDirs.Any( s =\u003E fullPath.Contains( s, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\treturn true;\r\n\r\n\t\t// any path segment starting with \u0027.\u0027\r\n\t\treturn fullPath.Replace( \u0027\\\\\u0027, \u0027/\u0027 ).Split( \u0027/\u0027 ).Any( seg =\u003E seg.StartsWith( \u0027.\u0027 ) \u0026\u0026 seg.Length \u003E 1 );\r\n\t}\r\n\r\n\t[McpTool( \u0022code_list_files\u0022, \u0022Lists source files in the project: C# (.cs), UI (.razor/.scss) and shaders. Saving a file hot-reloads automatically.\u0022, ToolCategory.Code )]\r\n\tpublic static object ListFiles(\r\n\t\t[Desc( \u0022Subdirectory filter relative to project root, e.g. \u0027Code/Player\u0027\u0022 )] string subdir = null,\r\n\t\t[Desc( \u0022Include files from installed Libraries\u0022 )] 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( $\u0022No directory \u0027{subdir}\u0027 in the project\u0022 );\r\n\r\n\t\tvar files = Directory.EnumerateFiles( searchRoot, \u0022*.*\u0022, SearchOption.AllDirectories )\r\n\t\t\t.Where( f =\u003E SourceExtensions.Contains( Path.GetExtension( f ), StringComparer.OrdinalIgnoreCase ) )\r\n\t\t\t.Where( f =\u003E !IsSkipped( f ) )\r\n\t\t\t.Where( f =\u003E includeLibraries || !f.Contains( Path.DirectorySeparatorChar \u002B \u0022Libraries\u0022 \u002B Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( f =\u003E Path.GetRelativePath( root, f ).Replace( \u0027\\\\\u0027, \u0027/\u0027 ) )\r\n\t\t\t.OrderBy( f =\u003E 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( \u0022code_search\u0022, \u0022Searches 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.\u0022, ToolCategory.Code )]\r\n\tpublic static object Search(\r\n\t\t[Desc( \u0022Text or regex to find\u0022 )] string pattern,\r\n\t\t[Desc( \u0022Treat pattern as a regular expression\u0022 )] bool regex = false,\r\n\t\t[Desc( \u0022Case-sensitive match\u0022 )] bool caseSensitive = false,\r\n\t\t[Desc( \u0022Limit to a subdirectory relative to project root\u0022 )] string subdir = null,\r\n\t\t[Desc( \u0022Also search installed library source under Libraries/\u0022 )] 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( \u0022pattern must not be empty\u0022 );\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( $\u0022No directory \u0027{subdir}\u0027 - use code_list_files to see the layout\u0022 );\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\u003Cobject\u003E();\r\n\r\n\t\tforeach ( var file in Directory.EnumerateFiles( searchRoot, \u0022*.*\u0022, 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 \u0026\u0026 file.Contains( Path.DirectorySeparatorChar \u002B \u0022Libraries\u0022 \u002B Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar rel = Path.GetRelativePath( root, file ).Replace( \u0027\\\\\u0027, \u0027/\u0027 );\r\n\t\t\tvar lines = File.ReadAllLines( file );\r\n\t\t\tfor ( var i = 0; i \u003C lines.Length; i\u002B\u002B )\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 \u002B 1, text = lines[i].Trim() } );\r\n\t\t\t\tif ( matches.Count \u003E= max ) break;\r\n\t\t\t}\r\n\t\t\tif ( matches.Count \u003E= max ) break;\r\n\t\t}\r\n\r\n\t\treturn new { count = matches.Count, truncated = matches.Count \u003E= max, matches };\r\n\t}\r\n\r\n\t[McpTool( \u0022code_read_file\u0022, \u0022Reads a project source file.\u0022, ToolCategory.Code )]\r\n\tpublic static object ReadFile( [Desc( \u0022Path relative to project root, e.g. \u0027Code/Player.cs\u0027\u0022 )] 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( $\u0022No file at \u0027{path}\u0027 - use code_list_files\u0022 );\r\n\r\n\t\treturn new { path, content = File.ReadAllText( absolute ) };\r\n\t}\r\n\r\n\t[McpTool( \u0022code_write_file\u0022, \u0022Writes a project source file (creating it if missing). The editor hot-reloads changed code automatically; check editor_get_logs / code_get_compile_errors afterwards.\u0022, ToolCategory.Code, Writes = true )]\r\n\tpublic static object WriteFile(\r\n\t\t[Desc( \u0022Path relative to project root, e.g. \u0027Code/Player.cs\u0027\u0022 )] string path,\r\n\t\t[Desc( \u0022Full new file content\u0022 )] 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 = \u0022hot-reload triggers automatically; verify with code_get_compile_errors\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022code_edit_file\u0022, \u0022Replaces 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.\u0022, ToolCategory.Code, Writes = true )]\r\n\tpublic static object EditFile(\r\n\t\t[Desc( \u0022Path relative to project root, e.g. \u0027Code/Player.cs\u0027\u0022 )] string path,\r\n\t\t[Desc( \u0022Exact existing text to replace (must be unique in the file, whitespace included)\u0022 )] string oldText,\r\n\t\t[Desc( \u0022Replacement text\u0022 )] string newText )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( oldText ) )\r\n\t\t\tthrow new ArgumentException( \u0022oldText must not be empty - use code_write_file to create/overwrite a file\u0022 );\r\n\r\n\t\tvar absolute = ResolveInProject( path );\r\n\t\tif ( !File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\u0022No file at \u0027{path}\u0027 - use code_list_files\u0022 );\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 \u003C 0 )\r\n\t\t\tthrow new InvalidOperationException( $\u0022The old text was not found in \u0027{path}\u0027 - read it with code_read_file and match exactly (whitespace included)\u0022 );\r\n\t\tif ( content.IndexOf( oldText, first \u002B 1, StringComparison.Ordinal ) \u003E= 0 )\r\n\t\t\tthrow new InvalidOperationException( $\u0022The old text appears more than once in \u0027{path}\u0027 - include more surrounding context to make it unique\u0022 );\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 = \u0022hot-reload triggers automatically; verify with code_get_compile_errors\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022code_create_component\u0022, \u0022Scaffolds 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.\u0022, ToolCategory.Code, Writes = true )]\r\n\tpublic static object CreateComponent(\r\n\t\t[Desc( \u0022Component class name, e.g. \u0027PlayerMovement\u0027\u0022 )] string className,\r\n\t\t[Desc( \u0022Namespace; omit for the project default\u0022 )] string @namespace = null,\r\n\t\t[Desc( \u0022Property fields as \u0027Type Name\u0027 pairs, e.g. [\u0027float Speed\u0027, \u0027GameObject Target\u0027]\u0022 )] string[] properties = null,\r\n\t\t[Desc( \u0022Add an OnUpdate() method body\u0022 )] bool withUpdate = true )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( className ) || !char.IsLetter( className[0] ) )\r\n\t\t\tthrow new ArgumentException( \u0022className must start with a letter\u0022 );\r\n\r\n\t\tvar ns = @namespace ?? DefaultNamespace();\r\n\t\tvar sb = new System.Text.StringBuilder();\r\n\t\tsb.AppendLine( \u0022using Sandbox;\u0022 ).AppendLine();\r\n\t\tsb.AppendLine( $\u0022namespace {ns};\u0022 ).AppendLine();\r\n\t\tsb.AppendLine( $\u0022public sealed class {className} : Component\u0022 );\r\n\t\tsb.AppendLine( \u0022{\u0022 );\r\n\r\n\t\tforeach ( var p in properties ?? Array.Empty\u003Cstring\u003E() )\r\n\t\t{\r\n\t\t\tvar parts = p.Split( \u0027 \u0027, StringSplitOptions.RemoveEmptyEntries );\r\n\t\t\tif ( parts.Length == 2 )\r\n\t\t\t\tsb.AppendLine( $\u0022\\t[Property] public {parts[0]} {parts[1]} {{ get; set; }}\u0022 ).AppendLine();\r\n\t\t}\r\n\r\n\t\tif ( withUpdate )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \u0022\\tprotected override void OnUpdate()\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t// runs every frame while the component is enabled\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t}\u0022 );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine( \u0022}\u0022 );\r\n\r\n\t\tvar path = $\u0022Code/{className}.cs\u0022;\r\n\t\tvar absolute = ResolveInProject( path );\r\n\t\tif ( File.Exists( absolute ) )\r\n\t\t\tthrow new InvalidOperationException( $\u0022\u0027{path}\u0027 already exists - edit it with code_write_file\u0022 );\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 = $\u0022hot-reloading; then component_add(go, \\\u0022{className}\\\u0022)\u0022 };\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, \u0022*.sbproj\u0022 ).FirstOrDefault();\r\n\t\t\tif ( sbproj is not null\r\n\t\t\t\t\u0026\u0026 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[\u0022Metadata\u0022]?[\u0022Compiler\u0022]?[\u0022RootNamespace\u0022]?.GetValue\u003Cstring\u003E()\r\n\t\t\t\t\t?? json[\u0022RootNamespace\u0022]?.GetValue\u003Cstring\u003E();\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 \u0022Sandbox\u0022;\r\n\t}\r\n\r\n\t[McpTool( \u0022code_run_static_method\u0022, \u0022Invokes 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\u003CT\u003E it is AWAITED and its result returned (not the Task object). Returns the result\u0027s ToString.\u0022, ToolCategory.Code, Writes = true )]\r\n\tpublic static async Task\u003Cobject\u003E RunStaticMethod(\r\n\t\t[Desc( \u0022Type name, e.g. \u0027MyGame.DebugHelpers\u0027\u0022 )] string typeName,\r\n\t\t[Desc( \u0022Public static method name\u0022 )] string methodName,\r\n\t\t[Desc( \u0022Positional argument values as a JSON array, e.g. [5, \\\u0022hi\\\u0022, true]; omit for a no-arg method\u0022 )] 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( $\u0022No type \u0027{typeName}\u0027 - is it compiled? Check code_get_compile_errors\u0022 );\r\n\r\n\t\tvar clrType = typeDesc.TargetType\r\n\t\t\t?? throw new InvalidOperationException( $\u0022\u0027{typeName}\u0027 has no usable CLR type\u0022 );\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 \u0022taking 0 arguments\u0022 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 =\u003E m.Name == methodName \u0026\u0026 !m.IsGenericMethodDefinition \u0026\u0026 m.GetParameters().Length == argCount )\r\n\t\t\t?? throw new InvalidOperationException(\r\n\t\t\t\t$\u0022\u0027{typeName}\u0027 has no public static method \u0027{methodName}\u0027 taking {argCount} argument(s) - use api_get_type to see its methods\u0022 );\r\n\r\n\t\t// marshal each JSON arg to the parameter\u0027s type (BindOptions resolves\r\n\t\t// engine value types like Vector3/Rotation) - a hard error if it can\u0027t\r\n\t\tvar parameters = method.GetParameters();\r\n\t\tvar bound = new object[parameters.Length];\r\n\t\tfor ( var i = 0; i \u003C parameters.Length; i\u002B\u002B )\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$\u0022Argument {i} (\u0027{parameters[i].Name}\u0027) could not be read as {parameters[i].ParameterType.Name}: {e.Message}\u0022 );\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\u003CT\u003E so a diagnostic method can be async without the\r\n\t\t// caller getting back \u0022System.Threading.Tasks.Task\u00601[System.String]\u0022\r\n\t\tvar awaited = await ToolHelpers.AwaitIfTask( result );\r\n\r\n\t\treturn new { invoked = $\u0022{typeName}.{methodName}\u0022, args = argCount, result = awaited?.ToString() ?? \u0022null\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022build_info\u0022, \u0022Reports 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.\u0022, ToolCategory.Code )]\r\n\tpublic static object BuildInfo(\r\n\t\t[Desc( \u0022Optional type to inspect, e.g. \u0027MyGame.DebugHelpers\u0027 - reports the assembly that holds it\u0022 )] string typeName = null )\r\n\t{\r\n\t\tstring Mvid( Assembly a ) =\u003E a.ManifestModule.ModuleVersionId.ToString( \u0022N\u0022 ).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( \u0022yyyy-MM-dd HH:mm:ss\u0022 );\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( $\u0022No type \u0027{typeName}\u0027 - is it compiled? Check code_get_compile_errors\u0022 );\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 ) ? \u0022(in-memory / hot-loaded)\u0022 : 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 = \u0022buildId (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.\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022code_delete_file\u0022, \u0022Deletes a project source file (e.g. remove a component you no longer need). Jailed to the project; not undoable.\u0022, ToolCategory.Code, Writes = true )]\r\n\tpublic static object DeleteFile( [Desc( \u0022Path relative to project root, e.g. \u0027Code/OldThing.cs\u0027\u0022 )] 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( $\u0022No file at \u0027{path}\u0027 - use code_list_files\u0022 );\r\n\r\n\t\tFile.Delete( absolute );\r\n\t\treturn new { deleted = path, note = \u0022the editor will hot-reload; check code_get_compile_errors for references you may need to remove\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022compile_await\u0022, \u0022Waits 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.\u0022, ToolCategory.Code )]\r\n\tpublic static async Task\u003Cobject\u003E CompileAwait(\r\n\t\t[Desc( \u0022Max seconds to wait for compilation to go quiet\u0022 )] 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 \u003C 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 \u0026\u0026 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 \u003E= 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 \u0022error\u0022\r\n\t\tvar errors = LogCapture.Recent( 300 )\r\n\t\t\t.Where( l =\u003E l.Message is not null \u0026\u0026 l.Message.Contains( \u0022error CS\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( l =\u003E 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 \u0022is the code I\u0027m calling actually the build I just made?\u0022\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( \u0022N\u0022 ).Substring( 0, 12 ),\r\n\t\t\tnote = !settled\r\n\t\t\t\t? \u0022Timed out before compilation went quiet - poll again or raise timeoutSeconds.\u0022\r\n\t\t\t\t: hotSwapped\r\n\t\t\t\t\t? \u0022Compilation settled and a hotload swapped the new code into the running process.\u0022\r\n\t\t\t\t\t: \u0022Compilation settled; no hotload observed (code may already be current, or an interface-shape change forced a full reload).\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022code_get_compile_errors\u0022, \u0022Gets recent compiler errors and warnings from the editor console.\u0022, ToolCategory.Code )]\r\n\tpublic static object GetCompileErrors( int max = 50 )\r\n\t{\r\n\t\tvar entries = LogCapture.Recent( max, \u0022warning\u0022, diagnosticsOnly: true )\r\n\t\t\t.Select( l =\u003E new { time = l.Time.ToString( \u0022HH:mm:ss\u0022 ), 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, \u0022error\u0022 )\r\n\t\t\t\t.Where( l =\u003E l.Message is not null )\r\n\t\t\t\t.Select( l =\u003E new { time = l.Time.ToString( \u0022HH:mm:ss\u0022 ), 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 = \u0022Entries 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.\u0022,\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( \u0022editor_get_logs\u0022, \u0022Reads 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).\u0022, ToolCategory.Editor )]\r\n\tpublic static object GetLogs(\r\n\t\tint count = 100,\r\n\t\t[Desc( \u0022Minimum severity: trace, info, warning or error\u0022 )] string minSeverity = null,\r\n\t\t[Desc( \u0022Only entries newer than this cursor (pass back the \u0027cursor\u0027 from the previous call to poll incrementally instead of re-reading old lines)\u0022 )] long sinceSeq = 0 )\r\n\t{\r\n\t\tvar logs = LogCapture.Recent( count, minSeverity, sinceSeq: sinceSeq )\r\n\t\t\t.Select( l =\u003E new { seq = l.Seq, time = l.Time.ToString( \u0022HH:mm:ss\u0022 ), 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 \u0022only what\u0027s new\u0022 tail\r\n\t\treturn new { count = logs.Length, cursor = LogCapture.LatestSeq, logs };\r\n\t}\r\n\r\n\t[McpTool( \u0022logs_search\u0022, \u0022Searches 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.\u0022, ToolCategory.Editor )]\r\n\tpublic static object LogsSearch(\r\n\t\t[Desc( \u0022Regex to match in the message; omit to match everything\u0022 )] string pattern = null,\r\n\t\t[Desc( \u0022Minimum severity: trace, info, warning or error\u0022 )] string minSeverity = null,\r\n\t\t[Desc( \u0022Only entries from the last N seconds; omit for the whole buffer\u0022 )] int withinSeconds = 0,\r\n\t\tint max = 50 )\r\n\t{\r\n\t\tvar since = withinSeconds \u003E 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 =\u003E new { seq = l.Seq, time = l.Time.ToString( \u0022HH:mm:ss\u0022 ), 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( \u0022editor_clear_logs\u0022, \u0022Clears the captured console log buffer.\u0022, 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( \u0022editor_screenshot\u0022, \u0022Captures what the game camera sees, as an image. DURING PLAY this is the player\u0027s 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\u0027s camera. For an arbitrary angle instead, use editor_screenshot_from. Needs an enabled CameraComponent.\u0022, ToolCategory.Editor )]\r\n\tpublic static object Screenshot(\r\n\t\t[Desc( \u0022Image width in pixels\u0022 )] int width = 1280,\r\n\t\t[Desc( \u0022Image height in pixels\u0022 )] int height = 720 )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar scene = session.IsPlaying \u0026\u0026 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\u0022The scene has no enabled CameraComponent to render from - add one with component_add\u0022 );\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( \u0022Rendering failed - check editor_get_logs; ensure a valid camera, or try editor_screenshot_from\u0022 );\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$\u0022{(session.IsPlaying ? \u0022game\u0022 : \u0022scene\u0022)} camera view, {width}x{height}\u0022 ) );\r\n\t}\r\n\r\n\t[McpTool( \u0022editor_screenshot_from\u0022, \u0022Renders the scene from an arbitrary viewpoint (no camera component needed) - use it to inspect what you built from any angle.\u0022, ToolCategory.Editor )]\r\n\tpublic static object ScreenshotFrom(\r\n\t\t[Desc( \u0022Camera world position [x, y, z]\u0022 )] float[] position,\r\n\t\t[Desc( \u0022Camera rotation [pitch, yaw, roll]; ignored when lookAt is set\u0022 )] float[] rotation = null,\r\n\t\t[Desc( \u0022GameObject id/name to aim the camera at\u0022 )] 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 = \u0022__mcp_temp_camera\u0022;\r\n\t\t\tgo.WorldPosition = ToVector3( position, \u0022position\u0022 );\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( \u0022\u0027rotation\u0027 must be [pitch, yaw, roll]\u0022 );\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\u003CCameraComponent\u003E();\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( \u0022Rendering failed\u0022 );\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$\u0022view from [{string.Join( \u0022, \u0022, position )}], {width}x{height}\u0022 ) );\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( \u0022editor_frame_object\u0022, \u0022Points the editor viewport camera at a GameObject so the user can see it.\u0022, ToolCategory.Editor )]\r\n\tpublic static object FrameObject( [Desc( \u0022GameObject id or unique name\u0022 )] 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( \u0022editor_play\u0022, \u0022Enters play mode with the current scene.\u0022, 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 = \u0022already in play mode\u0022 };\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( \u0022editor_stop\u0022, \u0022Exits play mode.\u0022, 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 = \u0022was not in play mode\u0022 };\r\n\r\n\t\tEditorScene.Stop();\r\n\t\treturn new { playing = false };\r\n\t}\r\n\r\n\t[McpTool( \u0022editor_is_playing\u0022, \u0022Whether the editor is currently in play mode.\u0022, 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( \u0022session_info\u0022, \u0022Play-session identity and timing - use it to tell restarts apart (play clones reuse the editor\u0027s GUIDs, so \u0027did the scene restart?\u0027 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.\u0022, ToolCategory.Editor )]\r\n\tpublic static object SessionInfo()\r\n\t{\r\n\t\tstring Stamp( System.DateTime? t ) =\u003E t?.ToString( \u0022yyyy-MM-dd HH:mm:ss\u0022 );\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( \u0022perf_get_stats\u0022, \u0022Measures the frame rate over a short window (by sampling the editor frame counter) and reports FPS \u002B 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\u0027s tick loop.\u0022, ToolCategory.Editor )]\r\n\tpublic static async Task\u003Cobject\u003E PerfGetStats(\r\n\t\t[Desc( \u0022Measurement window in seconds (0.2-10)\u0022 )] 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 \u003E 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 \u003E 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 = \u0022FPS is the editor frame loop (which is the game tick loop during play). GPU draw-call counters aren\u0027t exposed by the editor API. Measure before and after a change to compare.\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022editor_run_console_command\u0022, \u0022Runs an editor console command (e.g. \u0027clear\u0027, convars).\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object RunConsoleCommand( [Desc( \u0022The console command line to run\u0022 )] string command )\r\n\t{\r\n\t\tEditor.ConsoleSystem.Run( command );\r\n\t\treturn new { ran = command, note = \u0022check editor_get_logs for output\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022convar_get\u0022, \u0022Reads a console variable\u0027s value (game/engine settings).\u0022, ToolCategory.Editor )]\r\n\tpublic static object ConVarGet( [Desc( \u0022ConVar name, e.g. \u0027sv_gravity\u0027\u0022 )] 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( $\u0022No console variable \u0027{name}\u0027 - check the exact name with editor_run_console_command \u0027find {name}\u0027\u0022 );\r\n\r\n\t\treturn new { name, value };\r\n\t}\r\n\r\n\t[McpTool( \u0022convar_set\u0022, \u0022Sets a console variable\u0027s value.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object ConVarSet(\r\n\t\t[Desc( \u0022ConVar name\u0022 )] string name,\r\n\t\t[Desc( \u0022New value (string)\u0022 )] 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( \u0022editor_get_project_info\u0022, \u0022Gets the current project: title, ident, type, paths.\u0022, 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( \u0022No project is loaded\u0022 );\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( \u0022editor_get_selection\u0022, \u0022Gets the GameObjects currently selected in the editor.\u0022, 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\u003CGameObject\u003E()\r\n\t\t\t.Select( o =\u003E 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/// \u003Csummary\u003E\r\n/// Project configuration: input actions and startup scene.\r\n/// \u003C/summary\u003E\r\npublic static class ProjectTools\r\n{\r\n\t[McpTool( \u0022input_list_actions\u0022, \u0022Lists the project\u0027s input actions (the names used with Input.Pressed/Down in code).\u0022, 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( \u0022Input settings are unavailable - is a project loaded?\u0022 );\r\n\r\n\t\tvar actions = (settings.Actions ?? new())\r\n\t\t\t.Select( a =\u003E 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( \u0022input_add_action\u0022, \u0022Adds an input action to the project (use the name with Input.Pressed in code). Applies on next play.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object AddAction(\r\n\t\t[Desc( \u0022Action name, e.g. \u0027Dash\u0027\u0022 )] string name,\r\n\t\t[Desc( \u0022Keyboard key, e.g. \u0027shift\u0027, \u0027e\u0027, \u0027mouse1\u0027\u0022 )] string keyboardCode,\r\n\t\t[Desc( \u0022Group shown in settings UI, e.g. \u0027Movement\u0027\u0022 )] string group = \u0022Other\u0022 )\r\n\t{\r\n\t\tvar settings = ProjectSettings.Input\r\n\t\t\t?? throw new InvalidOperationException( \u0022Input settings are unavailable - is a project loaded?\u0022 );\r\n\r\n\t\tsettings.Actions ??= new();\r\n\r\n\t\tif ( !System.Text.RegularExpressions.Regex.IsMatch( name ?? \u0022\u0022, @\u0022^[a-zA-Z0-9_\\-]\u002B$\u0022 ) )\r\n\t\t\tthrow new ArgumentException( \u0022Action name may only contain letters, digits, underscore and hyphen (no spaces)\u0022 );\r\n\r\n\t\tif ( settings.Actions.Any( a =\u003E string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) ) )\r\n\t\t\tthrow new InvalidOperationException( $\u0022An input action named \u0027{name}\u0027 already exists - input_list_actions shows it; remove it first with input_remove_action\u0022 );\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 = \u0022IMPORTANT: input action bindings register on the NEXT play session, not the current one. If you\u0027re already in play mode, editor_stop then editor_play (or restart) before the binding works - otherwise Input.Pressed(\\\u0022\u0022 \u002B name \u002B \u0022\\\u0022) silently returns false.\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022input_remove_action\u0022, \u0022Removes an input action from the project.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object RemoveAction( [Desc( \u0022Action name\u0022 )] string name )\r\n\t{\r\n\t\tvar settings = ProjectSettings.Input\r\n\t\t\t?? throw new InvalidOperationException( \u0022Input settings are unavailable - is a project loaded?\u0022 );\r\n\r\n\t\tvar action = settings.Actions?.FirstOrDefault( a =\u003E string.Equals( a.Name, name, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022No input action named \u0027{name}\u0027 - use input_list_actions\u0022 );\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, \u0022ProjectSettings\u0022 );\r\n\t\tDirectory.CreateDirectory( dir );\r\n\t\t// use the config\u0027s 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, \u0022Input.config\u0022 ),\r\n\t\t\tsettings.Serialize().ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );\r\n\t}\r\n\r\n\t[McpTool( \u0022project_set_startup_scene\u0022, \u0022Sets the scene the game opens with when launched.\u0022, ToolCategory.Editor, Writes = true )]\r\n\tpublic static object SetStartupScene( [Desc( \u0022Scene asset path, e.g. \u0027scenes/main_menu.scene\u0027\u0022 )] string scenePath )\r\n\t{\r\n\t\tvar asset = AssetSystem.FindByPath( scenePath )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022No scene at \u0027{scenePath}\u0027 - use scene_list\u0022 );\r\n\r\n\t\tvar root = AssetTools.ProjectRoot;\r\n\t\tvar sbproj = Directory.GetFiles( root, \u0022*.sbproj\u0022 ).FirstOrDefault()\r\n\t\t\t?? throw new InvalidOperationException( \u0022No .sbproj file found in the project root\u0022 );\r\n\r\n\t\tvar json = JsonNode.Parse( File.ReadAllText( sbproj ) ) as JsonObject\r\n\t\t\t?? throw new InvalidOperationException( \u0022Could not parse the .sbproj file\u0022 );\r\n\r\n\t\tvar metadata = json[\u0022Metadata\u0022] as JsonObject;\r\n\t\tif ( metadata is null )\r\n\t\t\tjson[\u0022Metadata\u0022] = metadata = new JsonObject();\r\n\r\n\t\tmetadata[\u0022StartupScene\u0022] = 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( \u0022scene_get_status\u0022, \u0022Gets the active scene: name, play state, unsaved changes, object count.\u0022, 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 ?? \u0022active\u0022,\r\n\t\t\thasUnsavedChanges = session.HasUnsavedChanges,\r\n\t\t\tobjectCount = scene.GetAllObjects( false ).Count( o =\u003E o is not Sandbox.Scene ),\r\n\t\t\tselection = session.Selection.OfType\u003CSandbox.GameObject\u003E().Select( o =\u003E new { id = o.Id, name = o.Name } ).ToArray()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_setup_basic\u0022, \u0022Bootstraps 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.\u0022, ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SetupBasic(\r\n\t\t[Desc( \u0022Ground size multiplier (scales a dev box)\u0022 )] float groundScale = 10f )\r\n\t{\r\n\t\tvar session = RequireSession();\r\n\t\tvar box = Model.Load( \u0022models/dev/box.vmdl\u0022 );\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: setup basic scene\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar ground = session.Scene.CreateObject();\r\n\t\tground.Name = \u0022Ground\u0022;\r\n\t\tground.LocalScale = new Vector3( groundScale, groundScale, 1f );\r\n\t\tground.Components.Create\u003CModelRenderer\u003E().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\u003CBoxCollider\u003E();\r\n\r\n\t\tvar sun = session.Scene.CreateObject();\r\n\t\tsun.Name = \u0022Sun\u0022;\r\n\t\tsun.WorldRotation = Rotation.From( 60, 45, 0 );\r\n\t\tsun.Components.Create\u003CDirectionalLight\u003E();\r\n\r\n\t\tvar cam = session.Scene.CreateObject();\r\n\t\tcam.Name = \u0022Camera\u0022;\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\u003CCameraComponent\u003E().FieldOfView = 70f;\r\n\r\n\t\treturn new { created = new[] { \u0022Ground\u0022, \u0022Sun\u0022, \u0022Camera\u0022 }, note = \u0022ground has a collider; a directional light and camera are set - ready to build and play\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_diff\u0022, \u0022Compares 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.\u0022, 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 =\u003E o is not Sandbox.Scene ).Select( o =\u003E 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 ) \u0026\u0026 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( \u0022GameObjects\u0022, out var arr ) \u0026\u0026 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 =\u003E e.TryGetProperty( \u0022Name\u0022, out var n ) ? n.GetString() : null )\r\n\t\t\t\t\t\t.Where( n =\u003E 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 = \u0022scene has not been saved to disk yet (or its source file was not found)\u0022;\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tdiskNote = \u0022could not read/parse the disk scene: \u0022 \u002B 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? \u0022In-memory scene differs from disk - scene_save to persist (or you may lose these changes on restart).\u0022\r\n\t\t\t\t: \u0022In-memory scene matches the last save.\u0022)\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_target\u0022, \u0022Chooses which scene the object/component tools act on while PLAY mode is running: \u0027editor\u0027 = the persistent edit scene (plant a toggle/route/prop that survives Stop and restarts - the fix for losing objects to restarts), \u0027play\u0027 = the live throwaway play clone, \u0027active\u0027 (default) = whatever is focused. Set \u0027editor\u0027 before planting persistent objects during play, then reset to \u0027active\u0027. No effect when not playing.\u0022, ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SetSceneTarget( [Desc( \u0022\u0027editor\u0027, \u0027play\u0027, or \u0027active\u0027\u0022 )] string target = \u0022active\u0022 )\r\n\t{\r\n\t\tvar t = (target ?? \u0022active\u0022).ToLowerInvariant();\r\n\t\tif ( t is not (\u0022editor\u0022 or \u0022play\u0022 or \u0022active\u0022) )\r\n\t\t\tthrow new ArgumentException( \u0022target must be \u0027editor\u0027, \u0027play\u0027, or \u0027active\u0027\u0022 );\r\n\r\n\t\tToolHelpers.SceneTargetMode = t == \u0022active\u0022 ? 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 = \u0022Applies to subsequent object/component tools until changed. Reset to \u0027active\u0027 when done.\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_load_map\u0022, \u0022Imports 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 \u0027maps/mylevel.vmap\u0027 (find them with asset_search assetType \u0027vmap\u0027).\u0022, ToolCategory.Scene, Writes = true )]\r\n\tpublic static object LoadMap(\r\n\t\t[Desc( \u0022Map asset name/path, e.g. \u0027maps/mylevel.vmap\u0027\u0022 )] string mapName,\r\n\t\t[Desc( \u0022Name for the map GameObject\u0022 )] string objectName = \u0022Map\u0022,\r\n\t\t[Desc( \u0022World origin [x, y, z] for the map\u0022 )] float[] position = null )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( mapName ) )\r\n\t\t\tthrow new ArgumentException( \u0022mapName is required (e.g. \u0027maps/mylevel.vmap\u0027)\u0022 );\r\n\r\n\t\tvar session = RequireSession();\r\n\r\n\t\tusing var undo = session.UndoScope( \u0022MCP: load map\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tvar go = session.Scene.CreateObject();\r\n\t\tgo.Name = string.IsNullOrWhiteSpace( objectName ) ? \u0022Map\u0022 : objectName;\r\n\r\n\t\tif ( position is not null )\r\n\t\t\tgo.WorldPosition = ToVector3( position, \u0022position\u0022 );\r\n\r\n\t\tvar map = go.Components.Create\u003CMapInstance\u003E();\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( \u0022scene_add_asset\u0022, \u0022Adds any asset to the scene, dispatching by type: a model (.vmdl) -\u003E GameObject with a ModelRenderer; a prefab (.prefab) -\u003E instantiated; a map (.vmap) -\u003E GameObject with a MapInstance. The one-call \u0027put this asset in the scene\u0027. For materials/textures/sounds (which aren\u0027t scene objects), apply them to a component instead.\u0022, ToolCategory.Asset, Writes = true )]\r\n\tpublic static object AddAsset(\r\n\t\t[Desc( \u0022Asset path, e.g. \u0027models/x.vmdl\u0027, \u0027prefabs/y.prefab\u0027, \u0027maps/z.vmap\u0027\u0022 )] string path,\r\n\t\t[Desc( \u0022Object name; defaults to the asset\u0027s file name\u0022 )] string name = null,\r\n\t\t[Desc( \u0022World position [x, y, z]\u0022 )] float[] position = null )\r\n\t{\r\n\t\tif ( AssetSystem.FindByPath( path ) is null )\r\n\t\t\tthrow new InvalidOperationException( $\u0022No asset at \u0027{path}\u0027 - use asset_search to find it\u0022 );\r\n\r\n\t\tvar session = RequireSession();\r\n\t\tvar pos = position is null ? Vector3.Zero : ToVector3( position, \u0022position\u0022 );\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( \u0022MCP: add asset\u0022 ).WithGameObjectCreations().Push();\r\n\r\n\t\tswitch ( ext )\r\n\t\t{\r\n\t\t\tcase \u0022.vmdl\u0022:\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\u003CModelRenderer\u003E().Model = Model.Load( path );\r\n\t\t\t\treturn new { added = \u0022model\u0022, gameObject = go.Name, id = go.Id };\r\n\t\t\t}\r\n\t\t\tcase \u0022.prefab\u0022:\r\n\t\t\t{\r\n\t\t\t\tvar prefabFile = ResourceLibrary.Get\u003CPrefabFile\u003E( path )\r\n\t\t\t\t\t?? throw new InvalidOperationException( $\u0022Prefab \u0027{path}\u0027 could not be loaded\u0022 );\r\n\t\t\t\tvar prefabScene = SceneUtility.GetPrefabScene( prefabFile )\r\n\t\t\t\t\t?? throw new InvalidOperationException( $\u0022Prefab \u0027{path}\u0027 could not be loaded\u0022 );\r\n\t\t\t\tvar instance = prefabScene.Clone( new Transform( pos ) );\r\n\t\t\t\treturn new { added = \u0022prefab\u0022, gameObject = instance.Name, id = instance.Id };\r\n\t\t\t}\r\n\t\t\tcase \u0022.vmap\u0022:\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\u003CMapInstance\u003E().MapName = path;\r\n\t\t\t\treturn new { added = \u0022map\u0022, 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$\u0022Don\u0027t know how to add a \u0027{ext}\u0027 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.\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\t[McpTool( \u0022navmesh_generate\u0022, \u0022Enables and bakes the scene\u0027s 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.\u0022, ToolCategory.Scene, Writes = true )]\r\n\tpublic static object NavMeshGenerate(\r\n\t\t[Desc( \u0022Agent radius (character half-width)\u0022 )] float agentRadius = 16f,\r\n\t\t[Desc( \u0022Agent height\u0022 )] float agentHeight = 72f,\r\n\t\t[Desc( \u0022Max step height the agent can climb\u0022 )] 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( \u0022This scene has no NavMesh object\u0022 );\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 = \u0022generation may finish asynchronously; query paths with navmesh_find_path\u0022\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022navmesh_find_path\u0022, \u0022Finds a navigation path between two world points on the scene\u0027s NavMesh (for NPC/enemy movement) - returns the waypoints. Requires navmesh_generate first.\u0022, ToolCategory.Scene )]\r\n\tpublic static object NavMeshFindPath(\r\n\t\t[Desc( \u0022Start point [x, y, z]\u0022 )] float[] from,\r\n\t\t[Desc( \u0022Destination point [x, y, z]\u0022 )] 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( \u0022The scene\u0027s NavMesh is not enabled - call navmesh_generate first\u0022 );\r\n\r\n\t\tvar target = ToVector3( to, \u0022to\u0022 );\r\n\t\tvar path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest\r\n\t\t{\r\n\t\t\tStart = ToVector3( from, \u0022from\u0022 ),\r\n\t\t\tTarget = target\r\n\t\t} );\r\n\r\n\t\tvar points = path.Points is null ? Array.Empty\u003Cfloat[]\u003E() : path.Points.Select( p =\u003E V( p.Position ) ).ToArray();\r\n\t\tvar reaches = path.Status == Sandbox.Navigation.NavMeshPathStatus.Complete;\r\n\r\n\t\t// a Partial path\u0027s LAST waypoint is the closest reachable point, which the\r\n\t\t// engine leaves short of the target - callers must gate on \u0060reaches\u0060, never\r\n\t\t// on distance-to-last-point, or they\u0027ll treat unreachable targets as reached\r\n\t\tvar lastPos = points.Length \u003E 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: \u0022PARTIAL/failed path: the target is NOT reachable. \u0027endsAt\u0027 is the closest reachable point (endpointGap units short) - do not treat it as the destination. Gate movement/AI logic on \u0027reaches\u0027.\u0022\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( \u0022The scene\u0027s NavMesh is not enabled - call navmesh_generate first\u0022 );\r\n\r\n\t\treturn nav;\r\n\t}\r\n\r\n\t[McpTool( \u0022navmesh_random_point\u0022, \u0022Returns a random reachable point on the scene\u0027s NavMesh - for AI wander targets. Optionally sampled near a position within a radius. Requires navmesh_generate first.\u0022, ToolCategory.Scene )]\r\n\tpublic static object NavMeshRandomPoint(\r\n\t\t[Desc( \u0022Center to sample near [x, y, z]; omit for anywhere on the navmesh\u0022 )] float[] near = null,\r\n\t\t[Desc( \u0022Sample radius around \u0027near\u0027\u0022 )] 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, \u0022near\u0022 ), 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( \u0022navmesh_closest_point\u0022, \u0022Snaps a world point to the nearest point on the scene\u0027s NavMesh within a radius (clamp a spawn/target onto walkable ground). Requires navmesh_generate first.\u0022, ToolCategory.Scene )]\r\n\tpublic static object NavMeshClosestPoint(\r\n\t\t[Desc( \u0022World point [x, y, z]\u0022 )] float[] position,\r\n\t\t[Desc( \u0022Search radius\u0022 )] float radius = 200f )\r\n\t{\r\n\t\tvar nav = RequireNav();\r\n\t\tvar point = nav.GetClosestPoint( ToVector3( position, \u0022position\u0022 ), 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( \u0022scene_get_hierarchy\u0022, \u0022Gets the scene\u0027s GameObject tree with ids, names and component types.\u0022, ToolCategory.Scene )]\r\n\tpublic static object GetHierarchy(\r\n\t\t[Desc( \u0022How many levels deep to expand\u0022 )] int maxDepth = 4,\r\n\t\t[Desc( \u0022Id of a GameObject to use as the root; omit for the whole scene\u0022 )] 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 =\u003E DescribeTree( c, maxDepth - 1 ) ).ToArray()\r\n\t\t};\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_create\u0022, \u0022Creates a new scene (with a camera and a light) and makes it active. Save it with scene_save_as.\u0022, 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 = \u0022unsaved - use scene_save_as to write it to disk\u0022 };\r\n\t}\r\n\r\n\t[McpTool( \u0022scene_open\u0022, \u0022Opens a scene (or prefab) from disk in the editor and makes it active.\u0022, ToolCategory.Scene )]\r\n\tpublic static object Open( [Desc( \u0022Scene asset path, e.g. \u0027scenes/minimal.scene\u0027\u0022 )] string scenePath )\r\n\t{\r\n\t\tvar session = SceneEditorSession.CreateFromPath( scenePath )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022No scene at \u0027{scenePath}\u0027 - use scene_list\u0022 );\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( \u0022scene_list\u0022, \u0022Lists all scene assets in the project.\u0022, 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 =\u003E string.Equals( a.AssetType?.FileExtension, \u0022scene\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t.Select( a =\u003E a.Path )\r\n\t\t\t.OrderBy( p =\u003E 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( \u0022scene_save\u0022, \u0022Saves the active scene to disk. Fails for never-saved scenes - use scene_save_as for those.\u0022, 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( \u0022Cannot save while playing - editor_stop first (play-mode changes are discarded by design)\u0022 );\r\n\r\n\t\tif ( session.Scene.Source is null )\r\n\t\t\tthrow new InvalidOperationException( \u0022This scene has never been saved - use scene_save_as with a path\u0022 );\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( \u0022scene_save_as\u0022, \u0022Saves the active scene to a new path under Assets/ (works for never-saved scenes).\u0022, ToolCategory.Scene, Writes = true )]\r\n\tpublic static object SaveAs( [Desc( \u0022Assets-relative path ending in .scene, e.g. \u0027scenes/level1.scene\u0027\u0022 )] 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( \u0022Cannot save while playing - editor_stop first (play-mode changes are discarded by design)\u0022 );\r\n\r\n\t\tif ( scene is PrefabScene )\r\n\t\t\tthrow new InvalidOperationException( \u0022The active session is a prefab - prefabs save with scene_save, or use prefab_create_from_gameobject\u0022 );\r\n\r\n\t\tif ( !scenePath.EndsWith( \u0022.scene\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new ArgumentException( \u0022scenePath must end in .scene\u0022 );\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( \u0022scene\u0022, absolute )\r\n\t\t\t?? throw new InvalidOperationException( $\u0022Could not create a scene resource at \u0027{scenePath}\u0027 - is the path inside the project?\u0022 );\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\u0027s own Ctrl\u002BS runs)\r\n\t\tvar createSceneFile = typeof( Scene ).GetMethod( \u0022CreateSceneFile\u0022,\r\n\t\t\tSystem.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic )\r\n\t\t\t?? throw new InvalidOperationException( \u0022Scene.CreateSceneFile not found - the engine changed; report this\u0022 );\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\u0027s setter is internal - reflection again, matching the editor\u0027s save flow\r\n\t\ttypeof( Scene ).GetProperty( \u0022Source\u0022 )?.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( \u0022scene_undo\u0022, \u0022Undoes the last editor action.\u0022, 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( \u0022scene_redo\u0022, \u0022Redoes the last undone editor action.\u0022, 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"}]}