Editor/Server/ToolGateway.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using SboxMcp.Registry;
namespace SboxMcp.Server;
/// <summary>Keeps the full tool catalog server-side and exposes bounded lookup plus invocation.</summary>
public sealed class ToolGateway
{
public const string SearchToolName = "tool_search";
public const string CallToolName = "tool_call";
const int MaxResults = 5;
static readonly McpToolDescriptor[] GatewayDescriptors =
{
new( SearchToolName,
"Finds enabled s&box tools by exact name or intent. Returns at most 5 authoritative schemas. Use a precise query and limit=1 when possible, then use tool_call.",
Schema( new
{
query = new { type = "string", description = "Exact tool name or specific intent" },
limit = new { type = "integer", minimum = 1, maximum = MaxResults, @default = 3 },
category = new { type = "string", description = "Optional category, such as Scene, Component, Asset, Code, or Editor" }
}, "query" ) ),
new( CallToolName,
"Invokes a server-side tool found with tool_search. Its normal availability and permission checks still apply.",
Schema( new
{
name = new { type = "string", description = "Exact name returned by tool_search" },
arguments = new { type = "object", description = "Arguments matching the returned inputSchema" }
}, "name" ) )
};
readonly ToolRegistry _registry;
public IReadOnlyList<McpToolDescriptor> Descriptors => GatewayDescriptors;
public ToolGateway( ToolRegistry registry ) => _registry = registry;
public string Search( JsonElement? args )
{
var input = RequireObject( args );
var query = RequireString( input, "query" ).Trim();
if ( query.Length == 0 )
throw new ToolArgumentException( "Argument 'query' cannot be empty" );
if ( query.Length > 200 )
throw new ToolArgumentException( "Argument 'query' must be at most 200 characters" );
var limit = 3;
if ( input.TryGetProperty( "limit", out var limitValue )
&& (limitValue.ValueKind != JsonValueKind.Number
|| !limitValue.TryGetInt32( out limit ) || limit is < 1 or > MaxResults) )
throw new ToolArgumentException( $"Argument 'limit' must be an integer from 1 to {MaxResults}" );
var category = input.TryGetProperty( "category", out var categoryValue )
? categoryValue.GetString() : null;
if ( category is not null && !Enum.TryParse<ToolCategory>( category, true, out _ ) )
throw new ToolArgumentException( $"Unknown category '{category}'" );
var matches = _registry.Search( query, limit, category ).Select( tool => new
{
name = tool.Meta.Name,
description = tool.Descriptor.Description,
category = tool.Meta.Category.ToString(),
writes = tool.Meta.Writes,
inputSchema = tool.Descriptor.InputSchema
} );
return JsonSerializer.Serialize( new { query, matches } );
}
public (RegisteredTool Tool, JsonElement? Arguments) ResolveCall( JsonElement? args )
{
var input = RequireObject( args );
var name = RequireString( input, "name" );
var tool = _registry.Find( name )
?? throw new ToolArgumentException( $"Unknown tool '{name}'. Use {SearchToolName} first." );
if ( tool.UnavailableReason is string reason )
throw new ToolArgumentException( $"'{name}' is unavailable: {reason}" );
JsonElement? toolArgs = null;
if ( input.TryGetProperty( "arguments", out var value ) )
{
if ( value.ValueKind != JsonValueKind.Object )
throw new ToolArgumentException( "Argument 'arguments' must be an object" );
toolArgs = value;
}
return (tool, toolArgs);
}
static JsonElement RequireObject( JsonElement? value ) =>
value is { ValueKind: JsonValueKind.Object } element
? element : throw new ToolArgumentException( "Arguments must be an object" );
static string RequireString( JsonElement value, string name ) =>
value.TryGetProperty( name, out var property ) && property.ValueKind == JsonValueKind.String
? property.GetString() : throw new ToolArgumentException( $"Missing required string argument '{name}'" );
static JsonElement Schema( object properties, params string[] required ) =>
JsonSerializer.SerializeToElement( new { type = "object", properties, required, additionalProperties = false } );
}