Editor/Mcp/Tools/DocsTools.cs
using Sandbox;
using System;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using SboxMcp.Mcp.Docs;
namespace SboxMcp.Mcp.Tools;
/// <summary>
/// s&box documentation + API reference search. Runs entirely in-process, no
/// editor APIs needed — these tools work even with no scene loaded.
/// </summary>
[McpToolGroup]
public static class DocsTools
{
[McpTool( "sbox_search_docs",
Description = "Search s&box documentation for guides, tutorials, and concepts. Returns matching pages with titles, URLs, and relevant snippets." )]
public static async Task<object> SearchDocs(
[Description( "Search terms" )] string query,
[Description( "Max number of results (1-25, default 10)" )] int? limit = null,
[Description( "Optional category filter (e.g. 'Systems', 'About', 'Scenes')" )] string category = null )
{
var docs = DocsService.GetOrCreate();
await docs.EnsureDocsIndexedAsync( CancellationToken.None );
var actualLimit = Math.Clamp( limit ?? 10, 1, 25 );
var results = docs.DocSearch.Search( query, actualLimit, category );
if ( results.Count == 0 )
{
var hint = !string.IsNullOrEmpty( category )
? $" Try without the category filter \"{category}\"."
: "";
return $"No documentation found for \"{query}\".{hint}\n\nUse sbox_list_doc_categories to see what's available.";
}
var sb = new StringBuilder();
sb.AppendLine( $"## Search results for \"{query}\"" );
sb.AppendLine();
for ( var i = 0; i < results.Count; i++ )
{
var r = results[i];
sb.AppendLine( $"{i + 1}. **[{r.Title}]({r.Url})** — _{r.Category}_" );
if ( !string.IsNullOrEmpty( r.Snippet ) )
sb.AppendLine( $" > {r.Snippet}" );
sb.AppendLine();
}
sb.Append( $"_{results.Count} result(s). Use sbox_get_doc_page to read full content._" );
return sb.ToString();
}
[McpTool( "sbox_get_doc_page",
Description = "Fetch a specific s&box documentation page and return its content as Markdown. Supports chunked reading via start_index and max_length." )]
public static async Task<object> GetDocPage(
[Description( "Full URL of the documentation page (from docs.facepunch.com/s/sbox-dev/doc/...)" )] string url,
[Description( "Character offset to start reading from (default 0)" )] int? startIndex = null,
[Description( "Maximum content length in characters (100-20000, default 5000)" )] int? maxLength = null )
{
var docs = DocsService.GetOrCreate();
await docs.EnsureDocsIndexedAsync( CancellationToken.None );
var normalised = url.EndsWith( '/' ) ? url.Substring( 0, url.Length - 1 ) : url;
var page = docs.DocSearch.GetPage( normalised )
?? await docs.DocCrawler.CrawlSinglePageAsync( normalised, CancellationToken.None );
if ( page is null )
return $"Could not fetch the page at {url}. The page may not exist, require authentication, or be temporarily unavailable.";
var actualStart = Math.Max( 0, startIndex ?? 0 );
var actualMax = Math.Clamp( maxLength ?? 5000, 100, 20000 );
var totalLength = page.Markdown.Length;
var start = Math.Min( actualStart, totalLength );
var clampedLen = Math.Min( actualMax, totalLength - start );
var chunk = page.Markdown.Substring( start, clampedLen );
var endIndex = start + chunk.Length;
var hasMore = endIndex < totalLength;
var header = $"# {page.Title}\n\n**Section:** {page.Category} | **Source:** [{page.Url}]({page.Url})";
var lastUpdated = string.IsNullOrEmpty( page.LastUpdated ) ? "" : $"\n**Last updated:** {page.LastUpdated}";
var footer = hasMore
? $"\n\n---\n_Showing characters {start}–{endIndex} of {totalLength}. Use start_index={endIndex} to read the next chunk._"
: $"\n\n---\n_End of page ({totalLength} characters total)._";
return $"{header}{lastUpdated}\n\n---\n\n{chunk}{footer}";
}
[McpTool( "sbox_list_doc_categories",
Description = "List all available s&box documentation categories with page counts. Use this to discover what documentation is available before searching." )]
public static async Task<object> ListCategories()
{
var docs = DocsService.GetOrCreate();
await docs.EnsureDocsIndexedAsync( CancellationToken.None );
var categories = docs.DocSearch.GetCategories();
if ( categories.Count == 0 )
return "No documentation has been indexed yet. The server may still be crawling. Try again shortly.";
var sb = new StringBuilder();
sb.AppendLine( "## S&box Documentation Categories" );
sb.AppendLine();
sb.AppendLine( $"Total: {docs.DocSearch.PageCount} pages across {categories.Count} categories" );
sb.AppendLine();
foreach ( var cat in categories )
{
sb.AppendLine( $"### {cat.Name} ({cat.PageCount} pages)" );
sb.AppendLine();
foreach ( var page in cat.Pages )
sb.AppendLine( $"- [{page.Title}]({page.Url})" );
sb.AppendLine();
}
return sb.ToString();
}
[McpTool( "sbox_search_api",
Description = "Search the s&box API reference for classes, structs, interfaces, and their members. Use sbox_get_api_type for full details." )]
public static async Task<object> SearchApi(
[Description( "Type name, namespace, method name, or keyword" )] string query,
[Description( "Max number of results (1-20, default 8)" )] int? limit = null )
{
var docs = DocsService.GetOrCreate();
await docs.EnsureApiIndexedAsync( CancellationToken.None );
var actualLimit = Math.Clamp( limit ?? 8, 1, 20 );
var results = docs.ApiSearch.Search( query, actualLimit );
if ( results.Count == 0 )
return $"No API types found for \"{query}\".\n\nThe API reference covers all public types in the s&box engine.";
var sb = new StringBuilder();
sb.AppendLine( $"## API search results for \"{query}\"" );
sb.AppendLine();
for ( var i = 0; i < results.Count; i++ )
{
var r = results[i];
var ns = string.IsNullOrEmpty( r.Namespace ) ? "" : $" _({r.Namespace})_";
sb.AppendLine( $"{i + 1}. **[{r.Name}]({r.Url})**{ns}" );
if ( !string.IsNullOrEmpty( r.Description ) )
sb.AppendLine( $" > {r.Description}" );
if ( r.TopMembers.Count > 0 )
sb.AppendLine( $" Members: `{string.Join( "`, `", r.TopMembers )}`" );
sb.AppendLine();
}
sb.Append( $"_{results.Count} result(s). Use sbox_get_api_type for full details._" );
return sb.ToString();
}
[McpTool( "sbox_get_api_type",
Description = "Get full API reference for a specific s&box type: methods, properties, fields, signatures." )]
public static async Task<object> GetApiType(
[Description( "Short type name (e.g. 'Component') or fully-qualified name (e.g. 'Sandbox.Component')" )] string name,
[Description( "Character offset to start reading from (default 0)" )] int? startIndex = null,
[Description( "Maximum content length in characters (100-20000, default 5000)" )] int? maxLength = null )
{
var docs = DocsService.GetOrCreate();
await docs.EnsureApiIndexedAsync( CancellationToken.None );
var type = docs.ApiSearch.LookupType( name );
if ( type is null )
{
var hits = docs.ApiSearch.Search( name, 1 );
if ( hits.Count > 0 )
type = docs.ApiSearch.LookupType( hits[0].FullName );
}
if ( type is null )
return $"No API type found for \"{name}\".\n\nUse sbox_search_api to find the correct name.";
var actualStart = Math.Max( 0, startIndex ?? 0 );
var actualMax = Math.Clamp( maxLength ?? 5000, 100, 20000 );
return ApiSearch.FormatTypeDetail( type, actualStart, actualMax );
}
[McpTool( "sbox_cache_status",
Description = "Show the current status of the documentation cache and search index." )]
public static Task<object> CacheStatus()
{
var docs = DocsService.GetOrCreate();
var sb = new StringBuilder();
sb.AppendLine( "## S&box Docs MCP — Cache Status" );
sb.AppendLine();
sb.AppendLine( "### Documentation" );
sb.AppendLine( $"- **Index ready:** {(docs.DocIndexReady ? "Yes" : "No (still crawling…)")}" );
sb.AppendLine( $"- **Pages in cache:** {docs.DocCache.GetPageCount()}" );
sb.AppendLine( $"- **Pages in search index:** {docs.DocSearch.PageCount}" );
sb.AppendLine( $"- **Cache fresh:** {(docs.DocCache.IsFresh() ? "Yes" : "No (will re-crawl on next use)")}" );
sb.AppendLine();
sb.AppendLine( "### API Reference" );
sb.AppendLine( $"- **Index ready:** {(docs.ApiIndexReady ? "Yes" : "No (still loading…)")}" );
sb.AppendLine( $"- **Types in cache:** {docs.ApiCache.GetTypeCount()}" );
sb.AppendLine( $"- **Types in search index:** {docs.ApiSearch.TypeCount}" );
sb.AppendLine( $"- **Cache fresh:** {(docs.ApiCache.IsFresh() ? "Yes" : "No (will re-fetch on next use)")}" );
sb.AppendLine( $"- **Schema URL:** {(string.IsNullOrEmpty( docs.ApiCache.GetSchemaUrl() ) ? "(not yet fetched)" : docs.ApiCache.GetSchemaUrl())}" );
sb.AppendLine();
sb.AppendLine( "### Cache directory" );
sb.AppendLine( $"- {docs.DocCache.CacheDir}" );
return Task.FromResult<object>( sb.ToString() );
}
}