Editor/MCPServer.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.IO;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Linq;
using System.Numerics;
using System.Reflection;
using Editor;
using Sandbox;
using FileSystem = Editor.FileSystem;
using Braxnet.Commands;
namespace Braxnet;
public class MCPSession
{
public string SessionId { get; set; }
public DateTime CreatedAt { get; set; }
public bool Initialized { get; set; }
}
/// <summary>
/// Model Context Protocol (MCP) Server implementation for S&Box.
///
/// This server implements the MCP lifecycle including:
/// - Proper initialization handshake with protocol version negotiation
/// - Session management with lifecycle validation
/// - Notification handling (including the required notifications/initialized)
/// - Request/response processing with error handling
/// - Tools and commands registry
///
/// The server follows MCP specification version 2025-06-18.
/// </summary>
public class MCPServer
{
[ConVar( "mcp.port", ConVarFlags.Saved )]
public static int Port { get; set; } = 8098;
private static HttpListener _httpListener;
private static CancellationTokenSource _cancellationTokenSource;
// private static readonly ConcurrentDictionary<string, string> _sessions = new();
// private static readonly ConcurrentDictionary<string, bool> _sessionInitialized = new();
private static readonly ConcurrentDictionary<string, MCPSession> Sessions = new();
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
// Public accessor for commands to use
public static JsonSerializerOptions JsonOptions => _jsonOptions;
[Menu( "Editor", "MCP/Start MCP Server" )]
public static void StartMCPServer()
{
if ( _httpListener != null && _httpListener.IsListening )
{
Log.Info( "MCP Server is already running" );
return;
}
try
{
// Initialize command registry
MCPCommandRegistry.Initialize();
_httpListener = new HttpListener();
_httpListener.Prefixes.Add( $"http://127.0.0.1:{Port}/" );
_httpListener.Start();
_cancellationTokenSource = new CancellationTokenSource();
Task.Run( () => HandleRequests( _cancellationTokenSource.Token ) );
Log.Info( $"MCP Server started on port {Port}" );
}
catch ( Exception ex )
{
Log.Error( $"Failed to start MCP Server: {ex.Message}" );
}
}
[Menu( "Editor", "MCP/Stop MCP Server" )]
public static void StopMCPServer()
{
try
{
_cancellationTokenSource?.Cancel();
_httpListener?.Stop();
_httpListener?.Close();
_httpListener = null;
// _sessions.Clear();
// _sessionInitialized.Clear();
Sessions.Clear();
Log.Info( "MCP Server stopped" );
}
catch ( Exception ex )
{
Log.Error( $"Error stopping MCP Server: {ex.Message}" );
}
}
[Menu( "Editor", "MCP/Server Status" )]
public static void ShowServerStatus()
{
var isRunning = _httpListener != null && _httpListener.IsListening;
var activeSessionCount = GetActiveSessionCount();
var initializedSessionCount = GetInitializedSessionCount();
Log.Info( $"MCP Server Status:" );
Log.Info( $" Running: {isRunning}" );
Log.Info( $" Port: {Port}" );
Log.Info( $" Active Sessions: {activeSessionCount}" );
Log.Info( $" Initialized Sessions: {initializedSessionCount}" );
if ( activeSessionCount > 0 )
{
Log.Info( $" Sessions:" );
foreach ( var session in Sessions )
{
// var initialized = IsSessionInitialized( session.Key );
// Log.Info(
// $" {session.Key}: {(initialized ? "Initialized" : "Pending")} (Created: {session.Value})" );
Log.Info(
$" {session.Key}: {(session.Value.Initialized ? "Initialized" : "Pending")} (Created: {session.Value.CreatedAt})" );
}
}
}
[Menu( "Editor", "MCP/Cleanup Expired Sessions" )]
public static void MenuCleanupExpiredSessions()
{
CleanupExpiredSessions();
}
private static async Task HandleRequests( CancellationToken cancellationToken )
{
while ( !cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening )
{
try
{
var context = await _httpListener.GetContextAsync();
_ = Task.Run( () => ProcessRequest( context ), cancellationToken );
}
catch ( ObjectDisposedException )
{
break;
}
catch ( Exception ex )
{
Log.Error( $"Error handling HTTP request: {ex.Message}" );
}
}
}
private static async Task ProcessRequest( HttpListenerContext context )
{
var request = context.Request;
var response = context.Response;
// Set CORS headers
response.Headers.Add( "Access-Control-Allow-Origin", "*" );
response.Headers.Add( "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS" );
response.Headers.Add( "Access-Control-Allow-Headers",
"Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id" );
if ( request.HttpMethod == "OPTIONS" )
{
Log.Info( "Received CORS preflight request" );
response.StatusCode = 200;
response.Close();
return;
}
try
{
// Validate Origin header for security
var origin = request.Headers["Origin"];
if ( !string.IsNullOrEmpty( origin ) && !IsValidOrigin( origin ) )
{
Log.Warning( $"Invalid origin: {origin}" );
response.StatusCode = 403;
response.Close();
return;
}
/*foreach ( var header in request.Headers.AllKeys )
{
Log.Info( $"Request Header: {header} = {request.Headers[header]}" );
}*/
var sessionId = request.Headers["MCP-Session-Id"];
var protocolVersion = request.Headers["MCP-Protocol-Version"] ?? "2025-03-26";
if ( request.HttpMethod == "POST" )
{
await HandlePostRequest( request, response, sessionId, protocolVersion );
}
else if ( request.HttpMethod == "GET" )
{
await HandleGetRequest( request, response, sessionId, protocolVersion );
}
else if ( request.HttpMethod == "DELETE" )
{
await HandleDeleteRequest( request, response, sessionId );
}
else
{
Log.Warning( $"Unsupported HTTP method: {request.HttpMethod}" );
response.StatusCode = 405;
response.Close();
}
}
catch ( Exception ex )
{
Log.Error( $"Error processing request: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message );
}
}
private static bool IsValidOrigin( string origin )
{
// Allow localhost origins for development
return origin.StartsWith( "http://localhost:" ) ||
origin.StartsWith( "http://127.0.0.1:" ) ||
origin.StartsWith( "https://localhost:" ) ||
origin.StartsWith( "https://127.0.0.1:" );
}
private static async Task HandlePostRequest( HttpListenerRequest request, HttpListenerResponse response,
string sessionId, string protocolVersion )
{
using var reader = new StreamReader( request.InputStream, Encoding.UTF8 );
var body = await reader.ReadToEndAsync();
JsonRpcRequest jsonRpcRequest;
try
{
jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>( body, _jsonOptions );
}
catch ( JsonException ex )
{
await SendErrorResponse( response, -32700, "Parse error", ex.Message );
return;
}
if ( jsonRpcRequest == null )
{
await SendErrorResponse( response, -32600, "Invalid Request" );
return;
}
// Handle different message types
if ( jsonRpcRequest.Id != null )
{
Log.Info( $"Received JSON-RPC request: {jsonRpcRequest.Method} (ID: {jsonRpcRequest.Id})" );
// This is a request
await HandleJsonRpcRequest( jsonRpcRequest, response, sessionId, protocolVersion );
}
else
{
// This is a notification
Log.Info( $"Received notification: {jsonRpcRequest.Method}" );
// Handle notification through the registry
var handled = await MCPCommandRegistry.HandleNotificationAsync(
jsonRpcRequest.Method, jsonRpcRequest, sessionId, protocolVersion );
if ( !handled )
{
Log.Warning( $"No handler found for notification: {jsonRpcRequest.Method}" );
}
response.StatusCode = 202; // Accepted
response.Close();
}
}
private static async Task HandleGetRequest( HttpListenerRequest request, HttpListenerResponse response,
string sessionId, string protocolVersion )
{
// Handle SSE streams for server-initiated messages
var acceptHeader = request.Headers["Accept"];
if ( acceptHeader?.Contains( "text/event-stream" ) == true )
{
// Log.Info( $"Starting SSE stream for session {sessionId}" );
await StartServerSentEvents( response, sessionId );
}
else
{
Log.Warning( $"Unsupported Accept header: {acceptHeader}" );
response.StatusCode = 405;
response.Close();
}
}
private static async Task HandleDeleteRequest( HttpListenerRequest request, HttpListenerResponse response,
string sessionId )
{
if ( !string.IsNullOrEmpty( sessionId ) && Sessions.ContainsKey( sessionId ) )
{
Sessions.TryRemove( sessionId, out _ );
// _sessionInitialized.TryRemove( sessionId, out _ );
response.StatusCode = 200;
}
else
{
response.StatusCode = 404;
}
Log.Info( $"Session {sessionId} deleted" );
response.Close();
await Task.CompletedTask;
}
private static async Task StartServerSentEvents( HttpListenerResponse response, string sessionId )
{
response.ContentType = "text/event-stream";
response.Headers.Add( "Cache-Control", "no-cache" );
response.Headers.Add( "Connection", "keep-alive" );
try
{
var stream = response.OutputStream;
// Keep connection alive - in a real implementation, you'd send events here
await Task.Delay( 1000 );
// Log.Info( $"SSE stream closing for session {sessionId}" );
stream.Close();
}
catch ( Exception ex )
{
Log.Error( $"Error in SSE stream: {ex.Message}" );
}
}
private static async Task HandleJsonRpcRequest( JsonRpcRequest request, HttpListenerResponse response,
string sessionId, string protocolVersion )
{
try
{
// Check if response is still valid
/*if ( response.OutputStream == null )
{
Log.Warning( $"Response stream is null for request {request.Method}" );
return;
}*/
object result = null;
JsonRpcError error = null;
// Log.Info( $"Handling JSON-RPC request: {request.Method} (ID: {request.Id})" );
// Validate lifecycle - only allow initialize and ping before initialization is complete
if ( !string.IsNullOrEmpty( sessionId ) && !IsSessionInitialized( sessionId ) )
{
if ( request.Method != "initialize" && request.Method != "ping" )
{
Log.Warning( $"Request {request.Method} received before session {sessionId} was initialized" );
error = new JsonRpcError
{
Code = -32002,
Message = "Server not initialized",
Data =
"Must send initialize request and receive initialized notification before other requests"
};
}
}
if ( error == null )
{
// Use command registry instead of switch statement
result = await MCPCommandRegistry.ExecuteCommandAsync( request.Method, request, sessionId,
protocolVersion );
// Log.Info( $"Command execution result for {request.Method}: {result}" );
if ( result == null )
{
// Log.Warning( $"No result returned for command {request.Method}" );
error = new JsonRpcError { Code = -32601, Message = "Method not found", Data = request.Method };
}
}
Log.Info(
$"JSON-RPC request {request.Method} processed successfully, generate response with result type {result?.GetType().Name ?? "null"}" );
var jsonRpcResponse = new JsonRpcResponse { Id = request.Id, Result = result, Error = error };
// Log.Info( $"Generated JSON-RPC response: {jsonRpcResponse}" );
// Log.Info( $"Id: {jsonRpcResponse.Id}, Result: {jsonRpcResponse.Result}, Error: {jsonRpcResponse.Error}" );
// Log.Info(
// $"JSON-RPC response for {request.Method}: {JsonSerializer.Serialize( jsonRpcResponse, _jsonOptions )}" );
await SendJsonResponse( response, jsonRpcResponse );
}
catch ( ObjectDisposedException )
{
Log.Warning( $"Response was disposed while handling request {request.Method}" );
}
catch ( InvalidOperationException ex )
{
Log.Error( $"Invalid operation while handling JSON-RPC request {request.Method}: {ex.Message}" );
}
catch ( Exception ex )
{
Log.Error( $"Error handling JSON-RPC request {request.Method}: {ex.Message}" );
try
{
await SendErrorResponse( response, -32603, "Internal error", ex.Message, request.Id );
}
catch ( Exception errorEx )
{
Log.Warning( $"Could not send error response for {request.Method}: {errorEx.Message}" );
}
}
}
// Helper methods for commands
public static void RegisterSession( string sessionId )
{
// _sessions[sessionId] = DateTime.UtcNow.ToString();
// _sessionInitialized[sessionId] = false;
Sessions[sessionId] = new MCPSession
{
SessionId = sessionId, CreatedAt = DateTime.UtcNow, Initialized = false
};
}
public static void MarkSessionInitialized( string sessionId )
{
if ( string.IsNullOrEmpty( sessionId ) )
{
Log.Error( "Cannot mark session as initialized: sessionId is null or empty" );
return;
}
// _sessionInitialized[sessionId] = true;
if ( Sessions.TryGetValue( sessionId, out var session ) )
{
session.Initialized = true;
}
else
{
Log.Warning( $"Session {sessionId} not found when marking as initialized" );
return;
}
Log.Info( $"Session {sessionId} marked as initialized" );
}
public static bool IsSessionInitialized( string sessionId )
{
// return _sessionInitialized.TryGetValue( sessionId, out var initialized ) && initialized;
if ( Sessions.TryGetValue( sessionId, out var session ) )
{
return session.Initialized;
}
Log.Warning( $"Session {sessionId} not found when checking initialization" );
return false;
}
public static void CleanupExpiredSessions()
{
/*// Optional: Add logic to clean up old sessions
var expiredSessions = _sessions.Where( kvp =>
{
if ( DateTime.TryParse( kvp.Value, out var created ) )
{
return DateTime.UtcNow.Subtract( created ).TotalHours > 24; // 24 hour expiry
}
return true; // Remove sessions with invalid timestamps
} ).Select( kvp => kvp.Key ).ToList();
foreach ( var sessionId in expiredSessions )
{
_sessions.TryRemove( sessionId, out _ );
_sessionInitialized.TryRemove( sessionId, out _ );
Log.Info( $"Cleaned up expired session: {sessionId}" );
}*/
var expiredSessions = Sessions.Where( kvp => DateTime.UtcNow.Subtract( kvp.Value.CreatedAt ).TotalHours > 24 )
.Select( kvp => kvp.Key ).ToList();
foreach ( var sessionId in expiredSessions )
{
if ( Sessions.TryRemove( sessionId, out var removedSession ) )
{
Log.Info( $"Cleaned up expired session: {sessionId} (Created at {removedSession.CreatedAt})" );
}
else
{
Log.Warning( $"Failed to remove expired session: {sessionId}" );
}
}
Log.Info( $"Cleaned up {expiredSessions.Count} expired sessions" );
}
public static int GetActiveSessionCount() => Sessions.Count;
public static int GetInitializedSessionCount() => Sessions.Count( kvp => kvp.Value.Initialized );
public static string GetMimeType( string filePath )
{
var extension = Path.GetExtension( filePath ).ToLowerInvariant();
return extension switch
{
".cs" => "text/x-csharp",
".json" => "application/json",
".txt" => "text/plain",
".md" => "text/markdown",
".xml" => "application/xml",
".html" => "text/html",
".css" => "text/css",
".js" => "text/javascript",
".ts" => "text/typescript",
_ => "text/plain"
};
}
private static async Task SendJsonResponse( HttpListenerResponse response, JsonRpcResponse jsonRpcResponse )
{
Log.Info( $"Sending JSON-RPC response for request ID {jsonRpcResponse.Id}" );
try
{
// Check if response has already been closed
if ( response.OutputStream == null || !response.OutputStream.CanWrite )
{
Log.Warning( $"Response stream for {jsonRpcResponse.Id} is not writable - already closed" );
return;
}
try
{
JsonSerializer.Serialize( jsonRpcResponse.Result, _jsonOptions );
}
catch ( JsonException ex )
{
Log.Error( $"Failed to serialize result for request ID {jsonRpcResponse.Id}: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message, jsonRpcResponse.Id );
return;
}
catch ( InvalidOperationException ex )
{
Log.Error(
$"Invalid operation while serializing result for request ID {jsonRpcResponse.Id}: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message, jsonRpcResponse.Id );
return;
}
string json;
try
{
json = JsonSerializer.Serialize( jsonRpcResponse,
new JsonSerializerOptions()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
} );
}
catch ( JsonException ex )
{
Log.Error( $"Failed to serialize JSON-RPC response: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message, jsonRpcResponse.Id );
return;
}
catch ( InvalidOperationException ex )
{
Log.Error( $"Invalid operation while serializing JSON-RPC response: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message, jsonRpcResponse.Id );
return;
}
catch ( Exception ex )
{
Log.Error( $"Unexpected error while serializing JSON-RPC response: {ex.Message}" );
Log.Error( ex );
await SendErrorResponse( response, -32603, "Internal error", ex.Message, jsonRpcResponse.Id );
return;
}
var buffer = Encoding.UTF8.GetBytes( json );
response.ContentType = "application/json";
response.ContentLength64 = buffer.Length;
response.StatusCode = 200; // OK
await response.OutputStream.WriteAsync( buffer, 0, buffer.Length );
response.OutputStream.Close();
Log.Info( $"Sent JSON-RPC response, closed response stream for request ID {jsonRpcResponse.Id}" );
}
catch ( ObjectDisposedException ex )
{
Log.Warning(
$"Response for response {jsonRpcResponse.Id} was disposed during SendJsonResponse: {ex.Message}" );
}
catch ( InvalidOperationException ex )
{
Log.Warning(
$"Response for response {jsonRpcResponse.Id} in invalid state during SendJsonResponse: {ex.Message}" );
}
catch ( HttpListenerException ex )
{
Log.Warning( $"HttpListener error for response {jsonRpcResponse.Id}: {ex.Message}" );
}
}
private static async Task SendErrorResponse( HttpListenerResponse response, int code, string message,
string data = null, object id = null )
{
Log.Error( $"Sending error response: Code={code}, Message={message}, Data={data}, Id={id}" );
try
{
if ( response.OutputStream == null || !response.OutputStream.CanWrite )
{
Log.Warning( "Response stream is not writable - already closed" );
return;
}
var error = new JsonRpcError { Code = code, Message = message, Data = data };
var errorResponse = new JsonRpcResponse { Id = id, Error = error };
await SendJsonResponse( response, errorResponse );
}
catch ( ObjectDisposedException ex )
{
Log.Warning( $"Response was disposed during SendErrorResponse: {ex.Message}" );
}
catch ( InvalidOperationException ex )
{
Log.Warning( $"Response in invalid state during SendErrorResponse{ex.Message}" );
}
catch ( HttpListenerException ex )
{
Log.Warning( $"HttpListener error during SendErrorResponse: {ex.Message}" );
}
catch ( Exception ex )
{
Log.Error( $"Unexpected error in SendErrorResponse: {ex.Message}" );
}
}
}