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