Editor/Mcp/Docs/DocsService.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace SboxMcp.Mcp.Docs;
/// <summary>
/// Singleton that owns the docs/API caches, crawlers, and search indexes.
/// Tools call <see cref="EnsureDocsIndexedAsync"/> / <see cref="EnsureApiIndexedAsync"/>
/// before every search; the first caller triggers the network crawl, subsequent
/// callers reuse the same Task.
///
/// Lifecycle: explicitly started/stopped from the dock widget. All HTTP traffic
/// flows through a single <see cref="HttpClient"/> for connection pooling.
/// </summary>
public sealed class DocsService : IDisposable
{
public static DocsService Instance { get; private set; }
private readonly DocCache _docCache = new();
private readonly ApiCache _apiCache = new();
private readonly DocSearch _docSearch = new();
private readonly ApiSearch _apiSearch = new();
private readonly DocCrawler _docCrawler;
private readonly ApiCrawler _apiCrawler;
private readonly HttpClient _http;
private Task _docIndexTask;
private Task _apiIndexTask;
private bool _docIndexReady;
private bool _apiIndexReady;
private readonly object _lock = new();
private readonly CancellationTokenSource _shutdown = new();
private bool _disposed;
public DocsService()
{
_http = new HttpClient();
_http.DefaultRequestHeaders.UserAgent.ParseAdd( "sbox-mcp/2.0 (+https://github.com/Facepunch/sbox)" );
_docCrawler = new DocCrawler( _docCache, _http );
_apiCrawler = new ApiCrawler( _apiCache, _http );
}
public DocCache DocCache => _docCache;
public ApiCache ApiCache => _apiCache;
public DocSearch DocSearch => _docSearch;
public ApiSearch ApiSearch => _apiSearch;
public DocCrawler DocCrawler => _docCrawler;
public bool DocIndexReady => _docIndexReady;
public bool ApiIndexReady => _apiIndexReady;
public static DocsService GetOrCreate()
{
if ( Instance is null )
{
Instance = new DocsService();
Instance.Start();
}
return Instance;
}
public void Start()
{
_ = EnsureDocsIndexedAsync( _shutdown.Token );
_ = EnsureApiIndexedAsync( _shutdown.Token );
}
public Task EnsureDocsIndexedAsync( CancellationToken ct )
{
if ( _docIndexReady ) return Task.CompletedTask;
lock ( _lock )
{
// Cold-start race: when the editor first boots, our crawl can run
// before s&box networking is ready, leaving the index empty. Retry on
// the next caller by clearing the cached task whenever it ended in
// an empty index.
if ( _docIndexTask is { IsCompleted: true } && _docSearch.PageCount == 0 )
_docIndexTask = null;
_docIndexTask ??= IndexDocsAsync( ct );
}
return _docIndexTask;
}
public Task EnsureApiIndexedAsync( CancellationToken ct )
{
if ( _apiIndexReady ) return Task.CompletedTask;
lock ( _lock )
{
if ( _apiIndexTask is { IsCompleted: true } && _apiSearch.TypeCount == 0 )
_apiIndexTask = null;
_apiIndexTask ??= IndexApiAsync( ct );
}
return _apiIndexTask;
}
private async Task IndexDocsAsync( CancellationToken ct )
{
try
{
_docCache.Init();
var stats = await _docCrawler.CrawlAllAsync( s =>
{
var done = s.Crawled + s.Failed + s.FromCache;
if ( done % 10 == 0 || done == s.Total )
{
var pct = s.Total > 0 ? done * 100 / s.Total : 0;
Log.Info( $"[MCP Docs] Crawling... {done}/{s.Total} ({pct}%)" );
}
}, ct );
Log.Info( $"[MCP Docs] Crawl complete: {stats.Crawled} fetched, {stats.FromCache} cached, {stats.Failed} failed" );
var pages = _docCache.GetAllPages();
_docSearch.BuildIndex( pages );
Log.Info( $"[MCP Docs] Doc search index ready: {pages.Count} pages indexed" );
// Only flip the ready flag if we actually have content. Empty index
// means networking wasn't up yet — let the next call retry.
if ( pages.Count > 0 ) _docIndexReady = true;
else Log.Info( "[MCP Docs] Empty index — next tool call will retry the crawl" );
}
catch ( OperationCanceledException ) { /* shutdown */ }
catch ( Exception ex )
{
Log.Warning( $"[MCP Docs] Doc indexing error: {ex.Message}" );
}
}
private async Task IndexApiAsync( CancellationToken ct )
{
try
{
_apiCache.Init();
var stats = await _apiCrawler.CrawlAllAsync(
msg => Log.Info( $"[MCP Docs] API: {msg}" ), ct );
Log.Info( $"[MCP Docs] API schema ready: {stats.TypeCount} types ({(stats.FromCache ? "from cache" : "freshly downloaded")})" );
var types = _apiCache.LoadTypes() ?? new List<ApiType>();
_apiSearch.BuildIndex( types );
Log.Info( $"[MCP Docs] API search index ready: {_apiSearch.TypeCount} types indexed" );
if ( _apiSearch.TypeCount > 0 ) _apiIndexReady = true;
else Log.Info( "[MCP Docs] Empty API index — next tool call will retry" );
}
catch ( OperationCanceledException ) { /* shutdown */ }
catch ( Exception ex )
{
Log.Warning( $"[MCP Docs] API indexing error: {ex.Message}" );
}
}
public void Dispose()
{
if ( _disposed ) return;
_disposed = true;
_shutdown.Cancel();
_http.Dispose();
if ( Instance == this ) Instance = null;
}
}