Editor utility class exposing MCP tools to list, search, read, write, edit, create, delete project source files, run static methods via reflection, and report build/compile status and errors. It interacts with project filesystem, editor logs, and the engine type library to support hot-reload workflows.
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
};
}
}