Editor/McpEditorTool.cs
// Disambiguate: both Editor.* and Sandbox.UI.* define these widget types.
// We want the Editor (Qt-style) variants here.
using Editor;
using Sandbox;
using Label = Editor.Label;
using LineEdit = Editor.LineEdit;
using Button = Editor.Button;
using Widget = Editor.Widget;
using Layout = Editor.Layout;
using ScrollArea = Editor.ScrollArea;
using System;
using System.Collections.Generic;
using SboxMcp.Mcp;
using SboxMcp.Mcp.Docs;
namespace SboxMcp;
/// <summary>
/// s&box Editor Dock entry point for the in-editor MCP server.
///
/// The dock owns a permanent <see cref="McpHttpServer"/> on port 29015 and the
/// <see cref="DocsService"/> singleton. While the editor is open, MCP clients
/// (Claude Code via SSE / Streamable HTTP) can connect to it and dispatch tool
/// calls directly — there's no separate dotnet sub-process and no extra IPC.
/// </summary>
[Dock( "Editor", "MCP Server", "smart_toy" )]
public class McpServerDock : Widget
{
public static McpServerDock Current { get; private set; }
public int CommandCount { get; set; }
private McpHttpServer _server;
private int _port = McpHttpServer.DefaultPort;
private readonly List<string> _logEntries = new();
private const int MaxLogEntries = 50;
private DateTime _startedAt;
private Label _statusDot;
private Label _statusText;
private Label _urlLabel;
private Button _toggleButton;
private Label _commandCountLabel;
private Label _uptimeLabel;
private Layout _logLayout;
private LineEdit _portField;
public McpServerDock( Widget parent ) : base( parent )
{
Current = this;
ConsoleCapture.EnsureHooked();
// Register with the runtime->editor bridge so HandlerDispatcher (in Code/)
// can push log/counter updates without a direct compile-time reference.
EditorBridge.LogMessage = AddLog;
EditorBridge.IncrementRequestCount = () => CommandCount++;
EditorBridge.GetStatus = GetStatus;
MinimumSize = 200;
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
BuildHeader();
BuildStats();
BuildLog();
StartServer();
// Kick off docs/api crawling in the background so first MCP call is fast.
try { DocsService.GetOrCreate(); } catch ( Exception ex ) { AddLog( $"Docs init error: {ex.Message}" ); }
}
public override void OnDestroyed()
{
if ( Current == this ) Current = null;
// Clear bridge callbacks so post-destroy events don't try to touch us.
if ( EditorBridge.LogMessage == (Action<string>)AddLog ) EditorBridge.LogMessage = null;
EditorBridge.IncrementRequestCount = null;
EditorBridge.GetStatus = null;
// Unsubscribe so post-destroy events don't try to update a dead widget.
if ( _server is not null )
_server.OnClientCountChanged -= OnServerClientCountChanged;
// Don't dispose the server itself — it's a process-wide singleton that
// must survive hot-reloads. It tears down with the editor process.
base.OnDestroyed();
}
public string GetStatus()
{
if ( _server is null ) return "MCP server not running.";
return _server.IsListening
? $"Listening on {_server.Url} | requests: {_server.RequestCount} | clients: {_server.ClientCount}"
: "MCP server stopped.";
}
private void BuildHeader()
{
// Single header row: [● status text + url] [stretch] [Port: ___] [Stop/Start]
var header = Layout.AddRow();
header.Spacing = 8;
_statusDot = new Label( "●", this );
_statusDot.SetStyles( "color: #e05252; font-size: 18px;" );
header.Add( _statusDot );
var textCol = header.AddColumn();
textCol.Spacing = 2;
_statusText = new Label( "Stopped", this );
textCol.Add( _statusText );
_urlLabel = new Label( $"http://localhost:{_port}/mcp", this );
_urlLabel.SetStyles( "color: #888888; font-size: 11px;" );
textCol.Add( _urlLabel );
header.AddStretchCell();
var portLabel = new Label( "Port:", this );
portLabel.SetStyles( "color: #aaaaaa; font-size: 11px;" );
header.Add( portLabel );
_portField = new LineEdit( this );
_portField.Text = _port.ToString();
_portField.MinimumWidth = 70;
_portField.MaximumWidth = 80;
_portField.TextEdited += v =>
{
if ( int.TryParse( v, out var p ) )
{
_port = p;
_urlLabel.Text = $"http://localhost:{_port}/mcp";
}
};
header.Add( _portField );
_toggleButton = new Button( "Stop", this );
_toggleButton.Icon = "stop";
_toggleButton.ToolTip = "Stop or start the MCP listener. Edit the port first if you want to bind a different port.";
_toggleButton.Clicked = () =>
{
if ( McpHttpServer.Instance is { IsListening: true } )
{
McpHttpServer.StopGlobal();
_server = null;
_startedAt = default;
AddLog( "Stopped." );
}
else
{
StartServer();
}
};
header.Add( _toggleButton );
}
private void BuildStats()
{
// Two equal-weight columns: Requests counter + Uptime.
// SSE-stream counter dropped — Streamable HTTP (Claude's default) doesn't
// hold persistent connections, the number was always 0 and confused users.
var statsRow = Layout.AddRow();
statsRow.Spacing = 12;
var cmdCol = statsRow.AddColumn();
cmdCol.Spacing = 2;
cmdCol.Add( new Label( "Requests", this ) );
_commandCountLabel = new Label( "0", this );
_commandCountLabel.SetStyles( "font-size: 18px; font-weight: bold;" );
cmdCol.Add( _commandCountLabel );
var uptimeCol = statsRow.AddColumn();
uptimeCol.Spacing = 2;
uptimeCol.Add( new Label( "Uptime", this ) );
_uptimeLabel = new Label( "--", this );
_uptimeLabel.SetStyles( "font-size: 18px; font-weight: bold;" );
uptimeCol.Add( _uptimeLabel );
}
private void BuildLog()
{
Layout.Add( new Label( "Log", this ) );
// Single styled container holds the full scrollable log. Each entry is a
// plain Label (transparent background) so the box looks unified.
var box = new Widget( this );
box.SetStyles( "background-color: #161616; border: 1px solid #2a2a2a; border-radius: 4px;" );
box.Layout = Layout.Column();
box.Layout.Margin = 0;
var scroll = new ScrollArea( box );
var canvas = new Widget( box );
canvas.SetStyles( "background-color: transparent;" );
canvas.Layout = Layout.Column();
canvas.Layout.Spacing = 2;
canvas.Layout.Margin = 8;
scroll.Canvas = canvas;
// AddLog rebuilds this layout (newest -> oldest, then a trailing stretch
// cell to keep entries anchored to the top in an under-filled box).
_logLayout = canvas.Layout;
box.Layout.Add( scroll, 1 );
Layout.Add( box, 1 );
}
[EditorEvent.Frame]
public void Frame()
{
if ( !_statusDot.IsValid() || !_statusText.IsValid() || !_toggleButton.IsValid() )
return;
// Always read from the singleton — survives our local _server reference being null
// across hot-reloads / Stop+Start cycles.
var live = McpHttpServer.Instance;
var listening = live is { IsListening: true };
var requests = live?.RequestCount ?? 0;
_toggleButton.Text = listening ? "Stop" : "Start";
_toggleButton.Icon = listening ? "stop" : "play_arrow";
_statusDot.SetStyles( listening
? "color: #52e052; font-size: 18px;"
: "color: #e05252; font-size: 18px;" );
_statusText.Text = listening
? (requests > 0 ? $"Listening — {requests} request(s) handled" : "Listening — waiting for requests")
: "Stopped";
_commandCountLabel.Text = (requests + CommandCount).ToString();
_portField.ReadOnly = listening;
if ( listening && _startedAt != default )
{
var elapsed = DateTime.Now - _startedAt;
_uptimeLabel.Text = elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours}h {elapsed.Minutes:D2}m"
: $"{elapsed.Minutes}m {elapsed.Seconds:D2}s";
}
else
{
_uptimeLabel.Text = "--";
}
}
public void AddLog( string message )
{
var entry = $"[{DateTime.Now:HH:mm:ss}] {message}";
_logEntries.Insert( 0, entry );
if ( _logEntries.Count > MaxLogEntries )
_logEntries.RemoveAt( _logEntries.Count - 1 );
// Wipe and rebuild: Labels first (newest -> oldest), then a stretch cell
// to keep them anchored to the top of the box when there are few entries.
_logLayout.Clear( true );
foreach ( var e in _logEntries )
{
var lbl = new Label( e, this );
lbl.SetStyles( "background-color: transparent; border: none; padding: 0; font-size: 11px; color: #c0c0c0;" );
_logLayout.Add( lbl );
}
_logLayout.AddStretchCell();
}
private void StartServer()
{
try
{
// Attach to the process-wide singleton — survives hot-reloads.
_server = McpHttpServer.GetOrStart( _port );
_startedAt = DateTime.Now;
// Subscribe (idempotent re-subscribes are harmless — events fire once per real change).
_server.OnClientCountChanged += OnServerClientCountChanged;
AddLog( $"Listening on http://localhost:{_port}/mcp" );
AddLog( $"Tools registered: {ToolRegistry.List().Count}" );
}
catch ( Exception ex )
{
Log.Warning( $"[MCP] Could not start server: {ex.Message}" );
AddLog( $"Error: {ex.Message}" );
_server = null;
}
}
private void OnServerClientCountChanged()
{
// Fired from the HTTP accept loop background thread — bounce onto the
// main thread before touching widgets, otherwise AddLog crashes silently.
var server = _server;
if ( server is null ) return;
var count = server.ClientCount;
MainThread.Queue( () =>
{
try { AddLog( $"Streams: {count}" ); } catch { /* dock may be torn down */ }
} );
}
}