Editor/Server/McpServer.cs

An MCP (Message/Tool Control Protocol) local HTTP server for the editor. Listens on 127.0.0.1 and handles JSON-RPC style requests to initialize sessions, list tools, call tools via an injected invoker, ping, and remove sessions; it tracks sessions and returns JSON results or raw payloads.

NetworkingFile Access
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using SboxMcp.Registry;

namespace SboxMcp.Server;

/// <summary>
/// Wrap a tool's return value in this to send a pre-built MCP result payload
/// (e.g. image content) instead of formatted text.
/// </summary>
public sealed record RawMcpResult( object Payload );

/// <summary>
/// MCP server speaking Streamable HTTP (single JSON response mode) on
/// 127.0.0.1. Tool execution is delegated to an injected invoker so the
/// hosting layer controls threading and permissions.
/// </summary>
public sealed class McpServer : IDisposable
{
	public const string EndpointPath = "/sbox-mcp";

	readonly ToolRegistry _registry;
	readonly Func<RegisteredTool, JsonElement?, Task<object>> _invoker;
	readonly ConcurrentDictionary<string, McpSession> _sessions = new();

	HttpListener _listener;

	public int Port { get; private set; }
	public bool IsRunning => _listener?.IsListening ?? false;
	public IReadOnlyCollection<McpSession> Sessions => _sessions.Values.ToArray();
	public string Url => $"http://127.0.0.1:{Port}{EndpointPath}";

	/// <summary>Raised on start/stop and whenever the session list changes.</summary>
	public event Action StateChanged;

	public McpServer( ToolRegistry registry, Func<RegisteredTool, JsonElement?, Task<object>> invoker )
	{
		_registry = registry;
		_invoker = invoker;
	}

	public void Start( int port )
	{
		if ( IsRunning )
			Stop();

		var listener = new HttpListener();
		listener.Prefixes.Add( $"http://127.0.0.1:{port}/" );
		listener.Start(); // throws HttpListenerException if the port is taken

		_listener = listener;
		Port = port;
		_ = AcceptLoop( listener );
		StateChanged?.Invoke();
	}

	public void Stop()
	{
		var listener = _listener;
		_listener = null;

		if ( listener is not null )
		{
			try { listener.Close(); }
			catch ( ObjectDisposedException ) { }
		}

		_sessions.Clear();
		StateChanged?.Invoke();
	}

	public void Dispose() => Stop();

	async Task AcceptLoop( HttpListener listener )
	{
		while ( listener.IsListening )
		{
			HttpListenerContext context;
			try
			{
				context = await listener.GetContextAsync();
			}
			catch ( Exception e ) when ( e is HttpListenerException or ObjectDisposedException or InvalidOperationException )
			{
				return; // listener stopped
			}

			_ = Task.Run( () => HandleRequest( context ) );
		}
	}

	async Task HandleRequest( HttpListenerContext context )
	{
		try
		{
			await Route( context );
		}
		catch ( Exception e )
		{
			try
			{
				await WriteJson( context.Response, 500,
					JsonRpcWriter.Error( null, JsonRpcError.InternalError, e.Message ) );
			}
			catch
			{
				// response already gone; nothing useful to do
			}
		}
	}

	async Task Route( HttpListenerContext context )
	{
		var request = context.Request;
		var response = context.Response;

		if ( !IsAllowedOrigin( request.Headers["Origin"] ) )
		{
			await WriteJson( response, 403, JsonRpcWriter.Error( null, JsonRpcError.InvalidRequest, "Forbidden origin" ) );
			return;
		}

		if ( !string.Equals( request.Url?.AbsolutePath, EndpointPath, StringComparison.Ordinal ) )
		{
			await WriteJson( response, 404, JsonRpcWriter.Error( null, JsonRpcError.InvalidRequest, "Unknown path" ) );
			return;
		}

		switch ( request.HttpMethod )
		{
			case "POST":
				await HandlePost( context );
				break;

			case "DELETE":
				var sessionId = request.Headers["Mcp-Session-Id"];
				if ( sessionId is not null && _sessions.TryRemove( sessionId, out _ ) )
					StateChanged?.Invoke();
				response.StatusCode = 200;
				response.Close();
				break;

			default:
				response.AddHeader( "Allow", "POST, DELETE" );
				await WriteJson( response, 405, JsonRpcWriter.Error( null, JsonRpcError.InvalidRequest, "Use POST" ) );
				break;
		}
	}

	static bool IsAllowedOrigin( string origin )
	{
		if ( string.IsNullOrEmpty( origin ) || origin == "null" )
			return true;

		return Uri.TryCreate( origin, UriKind.Absolute, out var uri )
			&& (uri.IsLoopback || uri.Host == "localhost");
	}

	async Task HandlePost( HttpListenerContext context )
	{
		string body;
		using ( var reader = new StreamReader( context.Request.InputStream, Encoding.UTF8 ) )
			body = await reader.ReadToEndAsync();

		JsonRpcRequest rpc;
		try
		{
			rpc = JsonRpcRequest.Parse( body );
		}
		catch ( JsonRpcParseException e )
		{
			await WriteJson( context.Response, 400, JsonRpcWriter.Error( null, JsonRpcError.ParseError, e.Message ) );
			return;
		}

		TouchSession( context.Request.Headers["Mcp-Session-Id"] );

		if ( rpc.IsNotification )
		{
			context.Response.StatusCode = 202;
			context.Response.Close();
			return;
		}

		var (status, json, newSessionId) = await Dispatch( rpc );

		if ( newSessionId is not null )
			context.Response.AddHeader( "Mcp-Session-Id", newSessionId );

		await WriteJson( context.Response, status, json );
	}

	void TouchSession( string sessionId )
	{
		if ( sessionId is not null && _sessions.TryGetValue( sessionId, out var session ) )
			session.Touch();
	}

	/// <summary>
	/// Clients rarely send DELETE; drop sessions idle for over 30 minutes so
	/// the list doesn't grow forever in a long editor session.
	/// </summary>
	void PruneStaleSessions()
	{
		var cutoff = DateTime.Now - TimeSpan.FromMinutes( 30 );

		foreach ( var stale in _sessions.Values.Where( s => s.LastSeen < cutoff ).ToArray() )
			_sessions.TryRemove( stale.Id, out _ );
	}

	async Task<(int Status, string Json, string NewSessionId)> Dispatch( JsonRpcRequest rpc )
	{
		switch ( rpc.Method )
		{
			case "initialize":
			{
				var requested = rpc.Params is { ValueKind: JsonValueKind.Object } p
					&& p.TryGetProperty( "protocolVersion", out var v ) ? v.GetString() : null;

				var session = new McpSession();
				if ( rpc.Params is { ValueKind: JsonValueKind.Object } pi
					&& pi.TryGetProperty( "clientInfo", out var ci )
					&& ci.ValueKind == JsonValueKind.Object
					&& ci.TryGetProperty( "name", out var name )
					&& name.ValueKind == JsonValueKind.String )
				{
					session.ClientName = name.GetString();
				}

				session.Touch();
				_sessions[session.Id] = session;
				PruneStaleSessions();
				StateChanged?.Invoke();

				return (200, JsonRpcWriter.Result( rpc.Id, McpResults.Initialize( McpVersion.Negotiate( requested ) ) ), session.Id);
			}

			case "ping":
				return (200, JsonRpcWriter.Result( rpc.Id, new { } ), null);

			case "tools/list":
				return (200, JsonRpcWriter.Result( rpc.Id,
					McpResults.ToolsList( _registry.Tools.Where( t => t.IsAvailable ).Select( t => t.Descriptor ) ) ), null);

			case "tools/call":
				return (200, await CallTool( rpc ), null);

			default:
				return (200, JsonRpcWriter.Error( rpc.Id, JsonRpcError.MethodNotFound, $"Method '{rpc.Method}' is not supported" ), null);
		}
	}

	async Task<string> CallTool( JsonRpcRequest rpc )
	{
		if ( rpc.Params is not { ValueKind: JsonValueKind.Object } p
			|| !p.TryGetProperty( "name", out var nameEl ) || nameEl.ValueKind != JsonValueKind.String )
		{
			return JsonRpcWriter.Error( rpc.Id, JsonRpcError.InvalidParams, "tools/call requires params.name" );
		}

		var name = nameEl.GetString();
		var tool = _registry.Find( name );
		if ( tool is null )
			return JsonRpcWriter.Error( rpc.Id, JsonRpcError.InvalidParams, $"Unknown tool '{name}'" );

		if ( tool.UnavailableReason is string reason )
		{
			var detail = tool.Meta.Requires is null ? reason : $"{reason} (requires '{tool.Meta.Requires}')";
			return JsonRpcWriter.Error( rpc.Id, JsonRpcError.InvalidParams, $"'{name}' is unavailable: {detail}" );
		}

		JsonElement? args = p.TryGetProperty( "arguments", out var a ) && a.ValueKind == JsonValueKind.Object
			? a : null;

		try
		{
			var result = await _invoker( tool, args );

			return result is RawMcpResult raw
				? JsonRpcWriter.Result( rpc.Id, raw.Payload )
				: JsonRpcWriter.Result( rpc.Id, McpResults.TextContent( ToolRegistry.FormatResult( result ) ) );
		}
		catch ( Exception e )
		{
			return JsonRpcWriter.Result( rpc.Id, McpResults.TextContent( e.Message, isError: true ) );
		}
	}

	static async Task WriteJson( HttpListenerResponse response, int status, string json )
	{
		var bytes = Encoding.UTF8.GetBytes( json );
		response.StatusCode = status;
		response.ContentType = "application/json";
		response.ContentLength64 = bytes.Length;
		await response.OutputStream.WriteAsync( bytes );
		response.Close();
	}
}