From 47278754ce7497828d10881987f89fdd163415ea Mon Sep 17 00:00:00 2001 From: John Davenport Date: Tue, 25 Mar 2025 03:53:23 -0400 Subject: [PATCH] bg --- .../MCPSharp.Example.BackgroundServer.csproj | 41 ++++ MCPSharp.Example.BackgroundServer/Program.cs | 40 ++++ MCPSharp.sln | 38 +++- MCPSharp/Core/MCPServer.cs | 40 ++-- MCPSharp/Core/MCPServerHost.cs | 198 ++++++++++++++++++ 5 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 MCPSharp.Example.BackgroundServer/MCPSharp.Example.BackgroundServer.csproj create mode 100644 MCPSharp.Example.BackgroundServer/Program.cs create mode 100644 MCPSharp/Core/MCPServerHost.cs diff --git a/MCPSharp.Example.BackgroundServer/MCPSharp.Example.BackgroundServer.csproj b/MCPSharp.Example.BackgroundServer/MCPSharp.Example.BackgroundServer.csproj new file mode 100644 index 0000000..c089391 --- /dev/null +++ b/MCPSharp.Example.BackgroundServer/MCPSharp.Example.BackgroundServer.csproj @@ -0,0 +1,41 @@ + + + + Exe + net9.0 + enable + enable + AnyCPU;x64 + False + + + + true + + + + embedded + + + + embedded + + + + embedded + + + + embedded + + + + + + + + + + + + diff --git a/MCPSharp.Example.BackgroundServer/Program.cs b/MCPSharp.Example.BackgroundServer/Program.cs new file mode 100644 index 0000000..19e290c --- /dev/null +++ b/MCPSharp.Example.BackgroundServer/Program.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using MCPSharp; +using MCPSharp.Model; +using MCPSharp.Model.Schemas; + +namespace MCPSharp.Example.BackgroundServer; + +/// +/// This example demonstrates how to run an MCP server in a background thread using MCPServerHost. +/// The server is started asynchronously and will continue to run even after the main thread exits. +/// +public class Program +{ + /// + /// The main entry point for the MCP server example. + /// + public static async Task Main() + { + // Start server in background thread + await using var server = await MCPServerHost.StartAsync("BackgroundServer", "1.0.0"); + + // Add a sample tool + MCPServer.AddToolHandler(new Tool() + { + Name = "greet", + Description = "A simple greeting tool", + InputSchema = new InputSchema + { + Type = "object", + Required = ["name"], + Properties = new Dictionary{ + {"name", new ParameterSchema{Type="string", Description="Name to greet"}} + } + } + }, (string name) => $"Hello, {name}! (from background server)"); + + await Task.Delay(-1); + } +} diff --git a/MCPSharp.sln b/MCPSharp.sln index 0c8531b..094647b 100644 --- a/MCPSharp.sln +++ b/MCPSharp.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.12.35707.178 @@ -13,54 +13,90 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPSharp.Example.Import", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPSharp.Example.OllamaChatCLI", "MCPSharp.Example.OllamaChatCLI\MCPSharp.Example.OllamaChatCLI.csproj", "{73FD6F85-CCEE-4D80-8E8B-154F94511DD2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPSharp.Example.BackgroundServer", "MCPSharp.Example.BackgroundServer\MCPSharp.Example.BackgroundServer.csproj", "{DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|x64.ActiveCfg = Debug|x64 {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|x64.Build.0 = Debug|x64 + {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Debug|x86.Build.0 = Debug|Any CPU {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|Any CPU.Build.0 = Release|Any CPU {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|x64.ActiveCfg = Release|x64 {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|x64.Build.0 = Release|x64 + {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|x86.ActiveCfg = Release|Any CPU + {1A4D1BB7-973C-49AD-9564-F45D5B7703EF}.Release|x86.Build.0 = Release|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|Any CPU.Build.0 = Debug|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|x64.ActiveCfg = Debug|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|x64.Build.0 = Debug|Any CPU + {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|x86.ActiveCfg = Debug|Any CPU + {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Debug|x86.Build.0 = Debug|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|Any CPU.ActiveCfg = Release|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|Any CPU.Build.0 = Release|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|x64.ActiveCfg = Release|Any CPU {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|x64.Build.0 = Release|Any CPU + {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|x86.ActiveCfg = Release|Any CPU + {7FF8D00E-9DBA-435D-80E4-4F1F7E860CDA}.Release|x86.Build.0 = Release|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|x64.ActiveCfg = Debug|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|x64.Build.0 = Debug|Any CPU + {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Debug|x86.Build.0 = Debug|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|Any CPU.Build.0 = Release|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|x64.ActiveCfg = Release|Any CPU {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|x64.Build.0 = Release|Any CPU + {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|x86.ActiveCfg = Release|Any CPU + {EAAC2C18-8A35-4CED-B407-582170A7A0F9}.Release|x86.Build.0 = Release|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|Any CPU.Build.0 = Debug|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|x64.ActiveCfg = Debug|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|x64.Build.0 = Debug|Any CPU + {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|x86.ActiveCfg = Debug|Any CPU + {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Debug|x86.Build.0 = Debug|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|Any CPU.ActiveCfg = Release|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|Any CPU.Build.0 = Release|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|x64.ActiveCfg = Release|Any CPU {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|x64.Build.0 = Release|Any CPU + {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|x86.ActiveCfg = Release|Any CPU + {6B4E00C3-6CAF-4493-9A4D-9AE602FA9E00}.Release|x86.Build.0 = Release|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|Any CPU.Build.0 = Debug|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|x64.ActiveCfg = Debug|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|x64.Build.0 = Debug|Any CPU + {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Debug|x86.Build.0 = Debug|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|Any CPU.ActiveCfg = Release|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|Any CPU.Build.0 = Release|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|x64.ActiveCfg = Release|Any CPU {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|x64.Build.0 = Release|Any CPU + {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|x86.ActiveCfg = Release|Any CPU + {73FD6F85-CCEE-4D80-8E8B-154F94511DD2}.Release|x86.Build.0 = Release|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|x64.ActiveCfg = Debug|x64 + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|x64.Build.0 = Debug|x64 + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Debug|x86.Build.0 = Debug|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|Any CPU.Build.0 = Release|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|x64.ActiveCfg = Release|x64 + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|x64.Build.0 = Release|x64 + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|x86.ActiveCfg = Release|Any CPU + {DD4ABB3D-89CF-4C9C-AF62-94CEC22B779D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MCPSharp/Core/MCPServer.cs b/MCPSharp/Core/MCPServer.cs index d55bad9..c65f0fd 100644 --- a/MCPSharp/Core/MCPServer.cs +++ b/MCPSharp/Core/MCPServer.cs @@ -12,14 +12,22 @@ namespace MCPSharp /// public class MCPServer { - private static readonly MCPServer _instance = new(); + private static MCPServer _instance = new(); + + /// + /// Gets the current server instance. + /// + public static MCPServer Instance => _instance; private readonly JsonRpc _rpc; private readonly Stream StandardOutput; private readonly ToolManager _toolManager = new() { - ToolChangeNotification = () => { if (EnableToolChangeNotification) - _= _instance._rpc.InvokeWithParameterObjectAsync("notifications/tools/list_changed", null);} + ToolChangeNotification = () => + { + if (EnableToolChangeNotification) + _ = _instance._rpc.InvokeWithParameterObjectAsync("notifications/tools/list_changed", null); + } }; private readonly ResourceManager _resouceManager = new(); @@ -53,14 +61,17 @@ public class MCPServer private MCPServer() { Implementation = new(); - _target = new(_toolManager, _resouceManager, Implementation); + _target = new(_toolManager, _resouceManager, Implementation); Console.SetOut(RedirectedOutput); - _rpc = new JsonRpc(new NewLineDelimitedMessageHandler(new StdioTransportPipe(), - new SystemTextJsonFormatter() { - JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions { - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase - } }), _target); + _rpc = new JsonRpc(new NewLineDelimitedMessageHandler(new StdioTransportPipe(), + new SystemTextJsonFormatter() + { + JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + } + }), _target); _rpc.StartListening(); } @@ -76,7 +87,7 @@ private MCPServer() /// Registers a tool with the server. /// /// - public static void Register() where T : class, new()=>_ = _instance.RegisterAsync(); + public static void Register() where T : class, new() => _ = _instance.RegisterAsync(); public async Task RegisterAsync() where T : class, new() { _toolManager.Register(); _resouceManager.Register(); } public static void AddToolHandler(Tool tool, Delegate func) => _instance._toolManager.AddToolHandler(new ToolHandler(tool, func.Method)); @@ -141,8 +152,11 @@ private async Task StartPingThreadAsync() } } } - - internal void Dispose() + + /// + /// Disposes the server and releases all resources. + /// + public void Dispose() { _cancellationTokenSource.Cancel(); _rpc.Dispose(); diff --git a/MCPSharp/Core/MCPServerHost.cs b/MCPSharp/Core/MCPServerHost.cs new file mode 100644 index 0000000..1c34814 --- /dev/null +++ b/MCPSharp/Core/MCPServerHost.cs @@ -0,0 +1,198 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; + +#nullable enable + +namespace MCPSharp +{ + /// + /// Provides a background thread execution model for MCPServer. + /// This class allows running an MCP server in a background thread with proper lifecycle management. + /// + /// + /// The MCPServerHost class implements IAsyncDisposable for proper cleanup of resources. + /// It manages server startup, shutdown, and cancellation with timeouts to prevent deadlocks. + /// + /// Example usage: + /// + /// await using var server = await MCPServerHost.StartAsync("MyServer", "1.0.0"); + /// // Server is now running in background + /// // When the using block exits, server will be properly disposed + /// + /// + public class MCPServerHost : System.IAsyncDisposable + { + private static readonly JoinableTaskFactory JoinableFactory = new(new JoinableTaskContext()); + + private static async Task TimeoutAfterAsync(Func operation, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var timeoutTask = Task.Delay(timeout, cts.Token); + var operationTask = JoinableFactory.RunAsync(operation); + + var completedTask = await Task.WhenAny(operationTask.Task, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + throw new TimeoutException(); + } + + await cts.CancelAsync(); // Cancel the timeout task + await operationTask.Task.ConfigureAwait(false); // Propagate any exceptions + } + + private Task? _serverLoop; + private CancellationTokenSource? _cts; + private readonly AutoResetEvent _ready; + private bool _disposed; + + private MCPServerHost() + { + _ready = new AutoResetEvent(false); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MCPServerHost)); + } + } + + /// + /// Creates and starts a new MCPServer instance running in a background thread. + /// + /// The name of the server. + /// The version of the server. + /// A running MCPServerHost instance that will manage the server's lifecycle. + /// Thrown when name or version is null or empty. + /// + /// The server is started in a background thread and will continue running until disposed. + /// The method waits for the server to be ready before returning, with a timeout to prevent deadlocks. + /// + public static async Task StartAsync(string name, string version) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Server name cannot be null or empty.", nameof(name)); + if (string.IsNullOrEmpty(version)) + throw new ArgumentException("Server version cannot be null or empty.", nameof(version)); + + var host = new MCPServerHost(); + await host.InitializeAsync(name, version); + return host; + } + + /// + /// Initializes the server host and starts the server in a background thread. + /// + /// The name of the server. + /// The version of the server. + /// A task representing the asynchronous initialization operation. + /// Thrown when the host has been disposed. + /// Thrown when the server fails to start within the timeout period. + /// + /// This method: + /// 1. Creates a cancellation token source for managing the server lifecycle + /// 2. Starts the server in a background thread + /// 3. Waits for the server to signal readiness with a timeout + /// + private async Task InitializeAsync(string name, string version) + { + ThrowIfDisposed(); + const int timeoutSeconds = 5; + _cts = new CancellationTokenSource(); + + // Start server and wait for ready signal + try + { + var serverTask = JoinableFactory.RunAsync(async () => + { + try + { + _ready.Set(); // Signal ready before starting server + await MCPServer.StartAsync(name, version).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + }); + _serverLoop = serverTask.Task; + + // Wait for ready signal with timeout + await TimeoutAfterAsync( + async () => await JoinableFactory.RunAsync(() => Task.FromResult(_ready.WaitOne())), + TimeSpan.FromSeconds(timeoutSeconds) + ).ConfigureAwait(false); + } + catch (Exception) + { + await DisposeAsync(); // Clean up on timeout + throw new TimeoutException("Server failed to start within the timeout period."); + } + } + + /// + /// Stops the server and releases all resources. + /// + /// + /// This method handles the graceful shutdown of the server: + /// 1. Cancels the server loop + /// 2. Waits for completion with a timeout to prevent deadlocks + /// 3. Cleans up resources including the cancellation token source + /// + /// If the server does not shut down within the timeout period (5 seconds), + /// the method will still proceed with cleanup to prevent hanging. + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_cts != null && _serverLoop != null) + { + try + { + // Cancel server and wait for completion with timeout + try + { + await JoinableFactory.RunAsync(async () => + { + await Task.Run(() => _cts?.Cancel()).ConfigureAwait(false); + if (_serverLoop != null) + { + await TimeoutAfterAsync( + () => _serverLoop, + TimeSpan.FromSeconds(5) + ).ConfigureAwait(false); + } + }).Task.ConfigureAwait(false); + } + catch (TimeoutException) + { + System.Diagnostics.Debug.WriteLine("Server shutdown timed out"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + System.Diagnostics.Debug.WriteLine($"Server loop terminated with error: {ex}"); + } + finally + { + MCPServer.Instance.Dispose(); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation occurs + } + finally + { + _cts.Dispose(); + _ready.Dispose(); + } + } + } + } +}