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 5f524ad8a..f8c959e71 100644 --- a/samples/EverythingServer/LoggingUpdateMessageSender.cs +++ b/samples/EverythingServer/LoggingUpdateMessageSender.cs @@ -5,7 +5,7 @@ namespace EverythingServer; -public class LoggingUpdateMessageSender(McpServer server, Func getMinLevel) : BackgroundService +public class LoggingUpdateMessageSender(McpServer server) : BackgroundService { readonly Dictionary _loggingLevelMap = new() { @@ -23,15 +23,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - var newLevel = (LoggingLevel)Random.Shared.Next(_loggingLevelMap.Count); + var msgLevel = (LoggingLevel)Random.Shared.Next(_loggingLevelMap.Count); var message = new { - Level = newLevel.ToString().ToLower(), - Data = _loggingLevelMap[newLevel], + Level = msgLevel.ToString().ToLower(), + Data = _loggingLevelMap[msgLevel], }; - if (newLevel > getMinLevel()) + if (msgLevel > server.LoggingLevel) { await server.SendNotificationAsync("notifications/message", message, cancellationToken: stoppingToken); } diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index a18a29461..acaa7a37c 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,46 @@ 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; -}); +var builder = WebApplication.CreateBuilder(args); -HashSet subscriptions = []; -var _minimumLoggingLevel = LoggingLevel.Debug; +// Dictionary of session IDs to a set of resource URIs they are subscribed to +// The value is a ConcurrentDictionary used as a thread-safe HashSet +// because .NET does not have a built-in concurrent HashSet +ConcurrentDictionary> subscriptions = new(); 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) => + { + if (mcpServer.SessionId == null) + { + // There is no sessionId if the serverOptions.Stateless is true + await mcpServer.RunAsync(token); + return; + } + try + { + subscriptions[mcpServer.SessionId] = new ConcurrentDictionary(); + // Start an instance of SubscriptionMessageSender for this session + using var subscriptionSender = new SubscriptionMessageSender(mcpServer, subscriptions[mcpServer.SessionId]); + await subscriptionSender.StartAsync(token); + // Start an instance of LoggingUpdateMessageSender for this session + using var loggingSender = new LoggingUpdateMessageSender(mcpServer); + await loggingSender.StartAsync(token); + await mcpServer.RunAsync(token); + } + finally + { + // This code runs when the session ends + subscriptions.TryRemove(mcpServer.SessionId, out _); + } + }; + }) .WithTools() .WithTools() .WithTools() @@ -40,11 +63,13 @@ .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { - var uri = ctx.Params?.Uri; - - if (uri is not null) + if (ctx.Server.SessionId == null) { - subscriptions.Add(uri); + throw new McpException("Cannot add subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) + { + subscriptions[ctx.Server.SessionId].TryAdd(uri, 0); await ctx.Server.SampleAsync([ new ChatMessage(ChatRole.System, "You are a helpful test server"), @@ -62,10 +87,13 @@ await ctx.Server.SampleAsync([ }) .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => { - var uri = ctx.Params?.Uri; - if (uri is not null) + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) { - subscriptions.Remove(uri); + subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); } return new EmptyResult(); }) @@ -126,13 +154,13 @@ await ctx.Server.SampleAsync([ throw new McpProtocolException("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 +173,8 @@ await ctx.Server.SampleAsync([ .WithLogging(b => b.SetResourceBuilder(resource)) .UseOtlpExporter(); -builder.Services.AddSingleton(subscriptions); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); +var app = builder.Build(); -builder.Services.AddSingleton>(_ => () => _minimumLoggingLevel); +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/SubscriptionMessageSender.cs b/samples/EverythingServer/SubscriptionMessageSender.cs index b071965dc..6cb895905 100644 --- a/samples/EverythingServer/SubscriptionMessageSender.cs +++ b/samples/EverythingServer/SubscriptionMessageSender.cs @@ -1,14 +1,14 @@ -using Microsoft.Extensions.Hosting; +using System.Collections.Concurrent; using ModelContextProtocol; using ModelContextProtocol.Server; -internal class SubscriptionMessageSender(McpServer server, HashSet subscriptions) : BackgroundService +internal class SubscriptionMessageSender(McpServer server, ConcurrentDictionary subscriptions) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - foreach (var uri in subscriptions) + foreach (var uri in subscriptions.Keys) { await server.SendNotificationAsync("notifications/resource/updated", new 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": "*" +}