Editor/Tools/ApiAndReferenceTools.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.Mcp
{
public sealed class CodeSearchQuery
{
public string Query { get; init; } = "";
public string? Kind { get; init; }
public string? Type { get; init; }
public string? Extension { get; init; }
public int? Year { get; init; }
public string? Package { get; init; }
public int Page { get; init; } = 1;
public int Limit { get; init; } = 20;
}
public sealed class CodeSearchResult
{
public string PackageIdent { get; init; } = "";
public string PackageTitle { get; init; } = "";
public string PackageType { get; init; } = "";
public string FilePath { get; init; } = "";
public string Snippet { get; init; } = "";
public string PackageUrl { get; init; } = "";
public string FileUrl { get; init; } = "";
public bool PublicOpenSource { get; init; } = true;
}
public sealed class CodeSearchPage
{
public CodeSearchQuery Query { get; init; } = new();
public int Total { get; init; }
public int Page { get; init; }
public int Limit { get; init; }
public bool HasMore { get; init; }
public string Transport { get; init; } = "";
public IReadOnlyList<CodeSearchResult> Items { get; init; } = Array.Empty<CodeSearchResult>();
}
public static partial class SboxMcpAssistant
{
private static readonly HttpClient SchemaClient = new() { Timeout = TimeSpan.FromSeconds(15) };
private static JsonDocument? _cachedSchema;
private static DateTime _schemaFetched;
private static string _schemaSource = "unknown";
[McpTool("pocketknife_query_schema")]
public static async Task<string> QuerySchema([Description("Type or method name to search for")] string query, [Description("Maximum results")] int limit = 10)
{
var started = DateTime.UtcNow;
var normalizedQuery = (query ?? "").Trim();
if (normalizedQuery.Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(new { QueryLength = normalizedQuery.Length, MaximumQueryLength = 200 }, ToolStatus.Failed, "schema.query_too_long", "Schema query must be 200 characters or fewer."));
try
{
var root = await LoadSchemaAsync();
if (root is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "schema.unavailable", "The official schema and local cache were unavailable.", remediation: "Set SBOX_SCHEMA_ENDPOINT or retry the current official schema release."));
var results = new List<JsonElement>();
if (root.Value.TryGetProperty("Types", out var types) && types.ValueKind == JsonValueKind.Array)
foreach (var type in types.EnumerateArray())
{
var text = type.ToString();
if (text.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)) { results.Add(type.Clone()); if (results.Count >= Math.Clamp(limit, 1, 100)) break; }
}
var schemaTypeCount = root.Value.TryGetProperty("Types", out var schemaTypes) && schemaTypes.ValueKind == JsonValueKind.Array ? schemaTypes.GetArrayLength() : 0;
var value = new { Query = normalizedQuery, Count = results.Count, Items = results, Source = _schemaSource, SchemaTypeCount = schemaTypeCount, CachedAt = _schemaFetched };
return McpJson.Write(new ToolOutcome<object> { Success = results.Count > 0, Status = results.Count > 0 ? ToolStatus.Ready : ToolStatus.Degraded, Value = value, DurationMs = (long)(DateTime.UtcNow - started).TotalMilliseconds, Warnings = results.Count == 0 ? new List<string> { "No matching schema types were found." } : new() });
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "schema.search_failed", "Schema search failed.", ex)); }
}
[McpTool("pocketknife_search_local_references")]
public static string SearchLocalReferences(
[Description("Search query")] string query,
[Description("Maximum results, 1-100")] int limit = 20,
[Description("Zero-based result offset")] int offset = 0,
[Description("Optional project filter")] string project = "",
[Description("Optional extension filter")] string extension = "",
[Description("Optional source label filter")] string source = "")
{
try
{
var normalized = (query ?? "").Trim();
if (normalized.Length > 200) return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Failed, "references.query_too_long", "Reference query must be 200 characters or fewer."));
var page = ReferenceIndexService.Search(new ReferenceSearchQuery { Query = normalized, Limit = Math.Clamp(limit, 1, 100), Offset = Math.Max(offset, 0), Project = project, Extension = extension, Source = source });
return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Ready(page));
}
catch (FileNotFoundException) { return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Unavailable, "references.catalog_missing", "The configured reference catalog was not found.", remediation: "Set SBOX_REFERENCE_ROOT or install the local reference catalog.")); }
catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Failed, "references.search_failed", "Indexed reference search failed.", ex)); }
}
[McpTool("pocketknife_rebuild_local_reference_index")]
public static string RebuildLocalReferenceIndex([Description("Force a complete rebuild")] bool force = false)
{
try { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Ready(ReferenceIndexService.Rebuild(force: force))); }
catch (FileNotFoundException) { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Failure(null, ToolStatus.Unavailable, "references.catalog_missing", "The configured reference catalog was not found.")); }
catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Failure(null, ToolStatus.Failed, "references.rebuild_failed", "Reference index rebuild failed.", ex)); }
}
[McpTool("pocketknife_get_local_reference")]
public static string GetLocalReference([Description("Stable reference result ID")] string id)
{
try
{
var item = ReferenceIndexService.Get(id);
return item is null ? McpJson.Write(ToolOutcome<ReferenceSearchItem>.Failure(null, ToolStatus.Degraded, "references.not_found", $"No indexed reference exists for '{id}'.")) : McpJson.Write(ToolOutcome<ReferenceSearchItem>.Ready(item));
}
catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceSearchItem>.Failure(null, ToolStatus.Failed, "references.get_failed", "Reference lookup failed.", ex)); }
}
[McpTool("pocketknife_search_live_codesearch")]
public static async Task<string> SearchLiveCodeSearch(
[Description("Search text")] string query,
[Description("Package kind, e.g. Game or Library")] string kind = "",
[Description("Package type filter")] string type = "",
[Description("File extension, e.g. cs")] string extension = "cs",
[Description("One-based result page")] int page = 1,
[Description("Results per page, 1-50")] int limit = 20)
{
var normalizedQuery = (query ?? "").Trim();
if (normalizedQuery.Length > 200) return McpJson.Write(ToolOutcome<CodeSearchPage>.Failure(null, ToolStatus.Failed, "codesearch.query_too_long", "Code Search query must be 200 characters or fewer."));
var request = new CodeSearchQuery { Query = normalizedQuery, Kind = kind, Type = type, Extension = extension, Page = Math.Max(1, page), Limit = Math.Clamp(limit, 1, 100) };
try { return McpJson.Write(await OfficialCodeSearchClient.SearchAsync(request)); }
catch (Exception ex) { return McpJson.Write(ToolOutcome<CodeSearchPage>.Failure(null, ToolStatus.Unavailable, "codesearch.transport_unavailable", "The official s&box Code Search transport could not be reached or parsed.", ex, "Install/start the local Playwright code-search worker, or retry when sbox.game is available.")); }
}
private static async Task<JsonElement?> LoadSchemaAsync()
{
if (_cachedSchema is not null && (DateTime.UtcNow - _schemaFetched).TotalHours < 24) return _cachedSchema.RootElement.Clone();
var path = Path.Combine(Project.Current?.GetRootPath() ?? Environment.CurrentDirectory, ".sbox", "schema_cache.json");
// Keep infrastructure endpoints out of the package. A configured Hearth endpoint
// is accepted only over HTTPS or loopback HTTP, then local/public fallbacks are tried.
var configuredEndpoint = Environment.GetEnvironmentVariable("HEARTH_SCHEMA_ENDPOINT")
?? Environment.GetEnvironmentVariable("SBOX_SCHEMA_ENDPOINT");
var releaseEndpoint = Environment.GetEnvironmentVariable("SBOX_SCHEMA_RELEASE_ENDPOINT") ?? "https://cdn.sbox.game/releases/2026-09-14-22-27-43.zip.json";
var endpoints = new[]
{
configuredEndpoint,
releaseEndpoint,
"http://127.0.0.1:5509/api/schema",
"https://sbox.game/api/schema"
}
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
.Select(endpoint => endpoint!.Trim())
.Where(IsAllowedSchemaEndpoint)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var url in endpoints)
{
try
{
using var response = await SchemaClient.GetAsync(url);
if (!response.IsSuccessStatusCode) continue;
var content = await response.Content.ReadAsStringAsync();
var trimmed = content.TrimStart();
// GUARD: Reject HTML responses (e.g. sbox.game SPA fallback)
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("<", StringComparison.OrdinalIgnoreCase))
{
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
_cachedSchema = JsonDocument.Parse(content);
_schemaSource = url;
_schemaFetched = DateTime.UtcNow;
return _cachedSchema.RootElement.Clone();
}
catch
{
// Try next endpoint
}
}
// Fallback: Use existing valid cached file on disk if available
try
{
if (File.Exists(path))
{
var diskContent = File.ReadAllText(path);
var trimmed = diskContent.TrimStart();
if (!string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith("<", StringComparison.OrdinalIgnoreCase))
{
_cachedSchema = JsonDocument.Parse(diskContent);
_schemaSource = "project-cache";
_schemaFetched = File.GetLastWriteTimeUtc(path);
return _cachedSchema.RootElement.Clone();
}
}
}
catch
{
// Cache file is corrupt or unreadable
}
return null;
}
private static bool IsAllowedSchemaEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
!string.IsNullOrWhiteSpace(uri.UserInfo) || uri.Query.Length > 0 || uri.Fragment.Length > 0)
return false;
return uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
(uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && uri.IsLoopback);
}
}
internal static class OfficialCodeSearchClient
{
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(20) };
private static readonly HttpClient WorkerClient = new() { Timeout = TimeSpan.FromSeconds(60) };
private static readonly Regex LinkRegex = new("href=[\\\"'](?<href>[^\\\"']+)[\\\"'][^>]*>(?<text>.*?)</a>", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
public static async Task<ToolOutcome<CodeSearchPage>> SearchAsync(CodeSearchQuery query)
{
var worker = Environment.GetEnvironmentVariable("SBOX_CODESEARCH_WORKER_URL") ?? "http://127.0.0.1:7273";
try
{
using var request = new StringContent(JsonSerializer.Serialize(query, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }), Encoding.UTF8, "application/json");
using var response = await WorkerClient.PostAsync(worker.TrimEnd('/') + "/v1/search", request);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(); var parsed = JsonSerializer.Deserialize<ToolOutcome<CodeSearchPage>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }); if (parsed is not null) return parsed;
}
}
catch { /* The worker is optional; fall through to the official page probe. */ }
var url = BuildUrl(query); var html = await Client.GetStringAsync(url); var items = ParseVisibleLinks(html).Take(query.Limit).ToArray();
if (items.Length == 0) return ToolOutcome<CodeSearchPage>.Degraded(new CodeSearchPage { Query = query, Page = query.Page, Limit = query.Limit, Transport = "official-page-no-static-results", Items = items }, "codesearch.blazor_results", "The official page loaded but returned no statically rendered result cards; a browser worker is required for Blazor results.", "Start the Playwright code-search worker to drive the official page.");
return ToolOutcome<CodeSearchPage>.Ready(new CodeSearchPage { Query = query, Page = query.Page, Limit = query.Limit, HasMore = items.Length == query.Limit, Transport = "official-page", Items = items });
}
private static string BuildUrl(CodeSearchQuery q)
{
var pairs = new List<string> { "q=" + Uri.EscapeDataString(q.Query) };
if (!string.IsNullOrWhiteSpace(q.Kind)) pairs.Add("kind=" + Uri.EscapeDataString(q.Kind));
if (!string.IsNullOrWhiteSpace(q.Type)) pairs.Add("type=" + Uri.EscapeDataString(q.Type));
if (!string.IsNullOrWhiteSpace(q.Extension)) pairs.Add("ext=" + Uri.EscapeDataString(q.Extension));
if (q.Page > 1) pairs.Add("page=" + q.Page);
return "https://sbox.game/codesearch?" + string.Join("&", pairs);
}
private static IEnumerable<CodeSearchResult> ParseVisibleLinks(string html)
{
foreach (Match match in LinkRegex.Matches(html))
{
var href = System.Net.WebUtility.HtmlDecode(match.Groups["href"].Value); var text = Regex.Replace(System.Net.WebUtility.HtmlDecode(match.Groups["text"].Value), "<.*?>", "").Trim();
if (!href.Contains("/package/", StringComparison.OrdinalIgnoreCase) && !href.Contains("/code/", StringComparison.OrdinalIgnoreCase)) continue;
var absolute = href.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? href : "https://sbox.game" + (href.StartsWith("/") ? href : "/" + href);
yield return new CodeSearchResult { PackageTitle = ToolGuard.Clip(text, 200), FilePath = text, FileUrl = absolute, PackageUrl = absolute, Snippet = "Official Code Search result" };
}
}
}
}