Editor/Tools/ServerTools.cs

Editor tools for the MCP server. Implements a Batch tool that runs multiple registered tools in sequence and logs each step, plus GetConfig and SetConfig tools to read and modify server settings (port, autostart).

File Access
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." };
	}
}