diff --git a/samples/EverythingServer/EverythingServer.csproj b/samples/EverythingServer/EverythingServer.csproj index d5046f7eb..eadf720ca 100644 --- a/samples/EverythingServer/EverythingServer.csproj +++ b/samples/EverythingServer/EverythingServer.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,14 +8,13 @@ - - + diff --git a/samples/EverythingServer/EverythingServer.http b/samples/EverythingServer/EverythingServer.http new file mode 100644 index 000000000..4903f9407 --- /dev/null +++ b/samples/EverythingServer/EverythingServer.http @@ -0,0 +1,77 @@ +@HostAddress = http://localhost:3001 + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "RestClient", + "version": "0.1.0" + }, + "capabilities": {}, + "protocolVersion": "2025-06-18" + } +} + +### + +@SessionId = ZwwM0VFEtKNOMBsP8D2VzQ + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "resources/list" +} + +### + +@resource_uri = test://direct/text/resource + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 3, + "method": "resources/subscribe", + "params": { + "uri": "{{resource_uri}}" + } +} + +### + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 4, + "method": "resources/unsubscribe", + "params": { + "uri": "{{resource_uri}}" + } +} + +### + +DELETE {{HostAddress}}/ +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} diff --git a/samples/EverythingServer/LoggingUpdateMessageSender.cs b/samples/EverythingServer/LoggingUpdateMessageSender.cs index 844aa70d8..969ae1057 100644 --- a/samples/EverythingServer/LoggingUpdateMessageSender.cs +++ b/samples/EverythingServer/LoggingUpdateMessageSender.cs @@ -1,11 +1,10 @@ -using Microsoft.Extensions.Hosting; -using ModelContextProtocol; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; namespace EverythingServer; -public class LoggingUpdateMessageSender(IMcpServer server, Func getMinLevel) : BackgroundService +public class LoggingUpdateMessageSender(IServiceProvider serviceProvider) : BackgroundService { readonly Dictionary _loggingLevelMap = new() { @@ -21,19 +20,35 @@ public class LoggingUpdateMessageSender(IMcpServer server, Func ge protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Wait for the application to fully start before trying to access the MCP server + await Task.Delay(2000, stoppingToken); + while (!stoppingToken.IsCancellationRequested) { - var newLevel = (LoggingLevel)Random.Shared.Next(_loggingLevelMap.Count); - - var message = new + try + { + // Try to get the server from the service provider + var server = serviceProvider.GetService(); + if (server != null) { - Level = newLevel.ToString().ToLower(), - Data = _loggingLevelMap[newLevel], - }; + var newLevel = (LoggingLevel)Random.Shared.Next(_loggingLevelMap.Count); - if (newLevel > getMinLevel()) + var message = new + { + Level = newLevel.ToString().ToLower(), + Data = _loggingLevelMap[newLevel], + }; + + if (newLevel > server.LoggingLevel) + { + await server.SendNotificationAsync("notifications/message", message, cancellationToken: stoppingToken); + } + } + } + catch (Exception ex) { - await server.SendNotificationAsync("notifications/message", message, cancellationToken: stoppingToken); + // Log the exception but don't crash the service + Console.WriteLine($"Error in LoggingUpdateMessageSender: {ex.Message}"); } await Task.Delay(15000, stoppingToken); diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index b976bcc0a..71dc3e1ea 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,9 +3,6 @@ using EverythingServer.Resources; using EverythingServer.Tools; using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -14,20 +11,28 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using System.Collections.Concurrent; -var builder = Host.CreateApplicationBuilder(args); -builder.Logging.AddConsole(consoleLogOptions => -{ - // Configure all logs to go to stderr - consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; -}); - -HashSet subscriptions = []; -var _minimumLoggingLevel = LoggingLevel.Debug; +var builder = WebApplication.CreateBuilder(args); builder.Services .AddMcpServer() - .WithStdioServerTransport() + .WithHttpTransport(options => + { + // Add a RunSessionHandler to remove all subscriptions for the session when it ends + options.RunSessionHandler = async (httpContext, mcpServer, token) => + { + try + { + await mcpServer.RunAsync(token); + } + finally + { + // This code runs when the session ends + SubscriptionManager.RemoveAllSubscriptions(mcpServer); + } + }; + }) .WithTools() .WithTools() .WithTools() @@ -40,11 +45,13 @@ .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { - var uri = ctx.Params?.Uri; - - if (uri is not null) + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot add subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) { - subscriptions.Add(uri); + SubscriptionManager.AddSubscription(uri, ctx.Server); await ctx.Server.SampleAsync([ new ChatMessage(ChatRole.System, "You are a helpful test server"), @@ -62,10 +69,13 @@ await ctx.Server.SampleAsync([ }) .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => { - var uri = ctx.Params?.Uri; - if (uri is not null) + if (ctx.Server.SessionId == null) { - subscriptions.Remove(uri); + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) + { + SubscriptionManager.RemoveSubscription(uri, ctx.Server); } return new EmptyResult(); }) @@ -126,13 +136,13 @@ await ctx.Server.SampleAsync([ throw new McpException("Missing required argument 'level'", McpErrorCode.InvalidParams); } - _minimumLoggingLevel = ctx.Params.Level; + // The SDK updates the LoggingLevel field of the IMcpServer await ctx.Server.SendNotificationAsync("notifications/message", new { Level = "debug", Logger = "test-server", - Data = $"Logging level set to {_minimumLoggingLevel}", + Data = $"Logging level set to {ctx.Params.Level}", }, cancellationToken: ct); return new EmptyResult(); @@ -145,10 +155,13 @@ await ctx.Server.SampleAsync([ .WithLogging(b => b.SetResourceBuilder(resource)) .UseOtlpExporter(); -builder.Services.AddSingleton(subscriptions); builder.Services.AddHostedService(); builder.Services.AddHostedService(); -builder.Services.AddSingleton>(_ => () => _minimumLoggingLevel); +var app = builder.Build(); + +app.UseHttpsRedirection(); + +app.MapMcp(); -await builder.Build().RunAsync(); +app.Run(); diff --git a/samples/EverythingServer/Properties/launchSettings.json b/samples/EverythingServer/Properties/launchSettings.json new file mode 100644 index 000000000..74cf457ef --- /dev/null +++ b/samples/EverythingServer/Properties/launchSettings.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7133;http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + } + } + } +} \ No newline at end of file diff --git a/samples/EverythingServer/SubscriptionManager.cs b/samples/EverythingServer/SubscriptionManager.cs new file mode 100644 index 000000000..65eab0d8d --- /dev/null +++ b/samples/EverythingServer/SubscriptionManager.cs @@ -0,0 +1,91 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; + +// This class manages subscriptions to resources by McpServer instances. +// The subscription information must be accessed in a thread-safe manner since handlers +// can run in parallel even in the context of a single session. +static class SubscriptionManager +{ + // Subscriptions tracks resource URIs to bags of McpServer instances (thread-safe via locking) + private static Dictionary> subscriptions = new(); + + // SessionSubscriptions is a secondary index to subscriptions to allow efficient removal of all + // subscriptions for a given session when it ends. (thread-safe via locking) + private static Dictionary /* uris */> sessionSubscriptions = new(); + + private static readonly object _subscriptionsLock = new(); + + public static void AddSubscription(string uri, IMcpServer server) + { + if (server.SessionId == null) + { + throw new McpException("Cannot add subscription for server with null SessionId"); + } + lock (_subscriptionsLock) + { + subscriptions[uri] ??= new List(); + subscriptions[uri].Add(server); + sessionSubscriptions[server.SessionId] ??= new List(); + sessionSubscriptions[server.SessionId].Add(uri); + } + } + + public static void RemoveSubscription(string uri, IMcpServer server) + { + if (server.SessionId == null) + { + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + lock (_subscriptionsLock) + { + if (subscriptions.ContainsKey(uri)) + { + // Remove the server from the list of subscriptions for the URI + subscriptions[uri] = subscriptions[uri].Where(s => s.SessionId != server.SessionId).ToList(); + if (subscriptions[uri]?.Count == 0) + { + subscriptions.Remove(uri); + } + } + // Remove the URI from the list of subscriptions for the session + sessionSubscriptions[server.SessionId]?.Remove(uri); + if (sessionSubscriptions[server.SessionId]?.Count == 0) + { + sessionSubscriptions.Remove(server.SessionId); + } + } + } + + public static IDictionary> GetSubscriptions() + { + lock (_subscriptionsLock) + { + // Return a copy of the subscriptions dictionary to avoid external modification + return subscriptions.ToDictionary(entry => entry.Key, + entry => entry.Value.ToList()); + } + } + + public static void RemoveAllSubscriptions(IMcpServer server) + { + if (server.SessionId is { } sessionId) + { + lock (_subscriptionsLock) + { + // Remove all subscriptions for the session + if (sessionSubscriptions.TryGetValue(sessionId, out var uris)) + { + foreach (var uri in uris) + { + subscriptions[uri] = subscriptions[uri].Where(s => s.SessionId != sessionId).ToList(); + if (subscriptions[uri]?.Count == 0) + { + subscriptions.Remove(uri); + } + } + sessionSubscriptions.Remove(sessionId); + } + } + } + } +} \ No newline at end of file diff --git a/samples/EverythingServer/SubscriptionMessageSender.cs b/samples/EverythingServer/SubscriptionMessageSender.cs index 774d98523..80deb6ab0 100644 --- a/samples/EverythingServer/SubscriptionMessageSender.cs +++ b/samples/EverythingServer/SubscriptionMessageSender.cs @@ -1,20 +1,34 @@ -using Microsoft.Extensions.Hosting; -using ModelContextProtocol; +using ModelContextProtocol; using ModelContextProtocol.Server; -internal class SubscriptionMessageSender(IMcpServer server, HashSet subscriptions) : BackgroundService +using System.Collections.Concurrent; +internal class SubscriptionMessageSender() : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Wait for the application to fully start before trying to access the MCP server + await Task.Delay(2000, stoppingToken); + while (!stoppingToken.IsCancellationRequested) { - foreach (var uri in subscriptions) + try { - await server.SendNotificationAsync("notifications/resource/updated", - new + foreach (var (uri, servers) in SubscriptionManager.GetSubscriptions()) + { + foreach (var server in servers) { - Uri = uri, - }, cancellationToken: stoppingToken); + await server.SendNotificationAsync("notifications/resource/updated", + new + { + Uri = uri, + }, cancellationToken: stoppingToken); + } + } + } + catch (Exception ex) + { + // Log the exception but don't crash the service + Console.WriteLine($"Error in SubscriptionMessageSender: {ex.Message}"); } await Task.Delay(5000, stoppingToken); diff --git a/samples/EverythingServer/appsettings.Development.json b/samples/EverythingServer/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/samples/EverythingServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/EverythingServer/appsettings.json b/samples/EverythingServer/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/EverythingServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}