From 25f3a9b022c540434a7b0915153fbb5dd6347cb4 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 10 Sep 2026 17:04:00 +0200 Subject: [PATCH 1/3] Resolve MCP handler services from the schema in stateless mode --- .../Extensions/ServiceCollectionExtensions.cs | 20 +++++-- .../Handlers/CallToolHandler.cs | 9 ++-- .../Handlers/GetPromptHandler.cs | 6 ++- .../Handlers/ListPromptsHandler.cs | 5 +- .../Handlers/ListToolsHandler.cs | 5 +- .../Handlers/ReadResourceHandler.cs | 6 ++- .../McpSchemaServiceProvider.cs | 21 ++++++++ .../Handlers/CallToolHandlerTests.cs | 28 +++++----- .../Adapters.Mcp.Tests/IntegrationTestBase.cs | 52 +++++++++++++++++++ ...telessTransport_ReturnsExpectedResult.json | 9 ++++ 10 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs create mode 100644 src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/IntegrationTestBase.CallTool_StatelessTransport_ReturnsExpectedResult.json diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs index 3a4d26917c5..0c497f5af8d 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs @@ -113,6 +113,12 @@ public static void AddMcpSchemaServices( var mcpServers = new ConcurrentDictionary(); services.AddSingleton(mcpServers); + var schemaServices = new McpSchemaServiceProvider(); + + services + .AddOptions() + .Configure((_, provider) => schemaServices.Bind(provider)); + var mcpServerBuilder = services .AddMcpServer(options => @@ -151,14 +157,18 @@ public static void AddMcpSchemaServices( #pragma warning restore MCPEXP002 }) .WithListPromptsHandler( - (context, _) => ValueTask.FromResult(ListPromptsHandler.Handle(context))) + (_, _) => ValueTask.FromResult(ListPromptsHandler.Handle(schemaServices))) .WithGetPromptHandler( - (context, _) => ValueTask.FromResult(GetPromptHandler.Handle(context))) + (context, _) => + ValueTask.FromResult(GetPromptHandler.Handle(context, schemaServices))) .WithReadResourceHandler( - (context, _) => ValueTask.FromResult(ReadResourceHandler.Handle(context))) + (context, _) => + ValueTask.FromResult(ReadResourceHandler.Handle(context, schemaServices))) .WithListToolsHandler( - (context, _) => ValueTask.FromResult(ListToolsHandler.Handle(context))) - .WithCallToolHandler(CallToolHandler.HandleAsync); + (_, _) => ValueTask.FromResult(ListToolsHandler.Handle(schemaServices))) + .WithCallToolHandler( + (context, cancellationToken) => + CallToolHandler.HandleAsync(context, schemaServices, cancellationToken)); foreach (var modifier in setup.ServerModifiers) { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/CallToolHandler.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/CallToolHandler.cs index 1fe4c4dc52d..d2b968cd333 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/CallToolHandler.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/CallToolHandler.cs @@ -24,10 +24,10 @@ internal static class CallToolHandler #endif public static async ValueTask HandleAsync( RequestContext context, + IServiceProvider schemaServices, CancellationToken cancellationToken) { - var services = context.Services!; - var registry = services.GetRequiredService(); + var registry = schemaServices.GetRequiredService(); if (!registry.TryGetTool(context.Params!.Name, out var tool)) { @@ -59,8 +59,9 @@ public static async ValueTask HandleAsync( }; } - var requestExecutor = services.GetRequiredService(); - var rootServiceProvider = services.GetRequiredService().ServiceProvider; + var requestExecutor = schemaServices.GetRequiredService(); + var rootServiceProvider = + schemaServices.GetRequiredService().ServiceProvider; var httpContext = rootServiceProvider.GetRequiredService().HttpContext!; var diagnosticEvents = requestExecutor.Schema.Services.GetRequiredService(); var requestBuilder = CreateRequestBuilder(httpContext); diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/GetPromptHandler.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/GetPromptHandler.cs index 209514a62f7..197d0599f5c 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/GetPromptHandler.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/GetPromptHandler.cs @@ -11,9 +11,11 @@ namespace HotChocolate.Adapters.Mcp.Handlers; internal static partial class GetPromptHandler { - public static GetPromptResult Handle(RequestContext context) + public static GetPromptResult Handle( + RequestContext context, + IServiceProvider schemaServices) { - var registry = context.Services!.GetRequiredService(); + var registry = schemaServices.GetRequiredService(); if (!registry.TryGetPrompt(context.Params!.Name, out var prompt)) { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListPromptsHandler.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListPromptsHandler.cs index 03fe30f2ce5..a8e28005e21 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListPromptsHandler.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListPromptsHandler.cs @@ -1,14 +1,13 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; namespace HotChocolate.Adapters.Mcp.Handlers; internal static class ListPromptsHandler { - public static ListPromptsResult Handle(RequestContext context) + public static ListPromptsResult Handle(IServiceProvider schemaServices) { - var registry = context.Services!.GetRequiredService(); + var registry = schemaServices.GetRequiredService(); return new ListPromptsResult { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListToolsHandler.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListToolsHandler.cs index 1d5192acb22..650b292a677 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListToolsHandler.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ListToolsHandler.cs @@ -1,14 +1,13 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; namespace HotChocolate.Adapters.Mcp.Handlers; internal static class ListToolsHandler { - public static ListToolsResult Handle(RequestContext context) + public static ListToolsResult Handle(IServiceProvider schemaServices) { - var registry = context.Services!.GetRequiredService(); + var registry = schemaServices.GetRequiredService(); return new ListToolsResult { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ReadResourceHandler.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ReadResourceHandler.cs index 2c42b63ec86..bb173127744 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ReadResourceHandler.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Handlers/ReadResourceHandler.cs @@ -8,9 +8,11 @@ namespace HotChocolate.Adapters.Mcp.Handlers; internal static class ReadResourceHandler { - public static ReadResourceResult Handle(RequestContext context) + public static ReadResourceResult Handle( + RequestContext context, + IServiceProvider schemaServices) { - var registry = context.Services!.GetRequiredService(); + var registry = schemaServices.GetRequiredService(); if (!registry.TryGetToolByViewResourceUri(context.Params!.Uri, out var tool)) { diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs new file mode 100644 index 00000000000..abe25eec58c --- /dev/null +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs @@ -0,0 +1,21 @@ +namespace HotChocolate.Adapters.Mcp; + +/// +/// Provides the schema-scoped services that the MCP request handlers resolve from. +/// +/// +/// The provider is bound to the schema services when the MCP server options are built. +/// +internal sealed class McpSchemaServiceProvider : IServiceProvider +{ + private IServiceProvider? _schemaServices; + + public void Bind(IServiceProvider schemaServices) + { + ArgumentNullException.ThrowIfNull(schemaServices); + + _schemaServices = schemaServices; + } + + public object? GetService(Type serviceType) => _schemaServices?.GetService(serviceType); +} diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/Handlers/CallToolHandlerTests.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/Handlers/CallToolHandlerTests.cs index 888817049fe..443a7d96458 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/Handlers/CallToolHandlerTests.cs +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/Handlers/CallToolHandlerTests.cs @@ -14,10 +14,13 @@ public sealed class CallToolHandlerTests public async Task HandleAsync_MissingTool_ReturnsCallToolResultWithError() { // arrange - var context = await CreateRequestContextAsync("unknown"); + var (context, schemaServices) = await CreateRequestContextAsync("unknown"); // act - var result = await CallToolHandler.HandleAsync(context, CancellationToken.None); + var result = await CallToolHandler.HandleAsync( + context, + schemaServices, + CancellationToken.None); // assert Assert.True(result.IsError); @@ -25,8 +28,8 @@ public async Task HandleAsync_MissingTool_ReturnsCallToolResultWithError() Assert.Equal("The tool 'unknown' was not found.", textContentBlock.Text); } - private static async Task> CreateRequestContextAsync( - string toolName) + private static async Task<(RequestContext, IServiceProvider)> + CreateRequestContextAsync(string toolName) { var storage = new TestMcpStorage(); await storage.AddOrUpdateToolAsync( @@ -49,15 +52,16 @@ await storage.AddOrUpdateToolAsync( var executorProvider = serviceProvider.GetRequiredService(); var executor = await executorProvider.GetExecutorAsync(); Mock mockServer = new(); - mockServer.SetupGet(s => s.Services).Returns(executor.Schema.Services); var request = new JsonRpcRequest { Method = RequestMethods.ToolsCall }; - return new RequestContext( - mockServer.Object, - request, - new CallToolRequestParams - { - Name = toolName - }); + return ( + new RequestContext( + mockServer.Object, + request, + new CallToolRequestParams + { + Name = toolName + }), + executor.Schema.Services); } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs index 6c281c35a04..9218a113bc9 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs @@ -660,6 +660,31 @@ await File.ReadAllTextAsync( .MatchSnapshot(extension: ".json"); } + [Fact] + public async Task ListTools_StatelessTransport_ReturnsTools() + { + // arrange + var storage = new TestMcpStorage(); + await storage.AddOrUpdateToolAsync( + new OperationToolDefinition( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync( + "__resources__/GetBooksWithTitle1.graphql", + TestContext.Current.CancellationToken))), + TestContext.Current.CancellationToken); + var server = + await CreateTestServerAsync( + storage, + configureMcpServer: b => b.WithHttpTransport(o => o.Stateless = true)); + var mcpClient = await CreateMcpClientAsync(server.CreateClient()); + + // act + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal("get_books_with_title1", Assert.Single(tools).Name); + } + [Fact] public async Task ListTools_AfterToolsUpdate_ReturnsUpdatedTools() { @@ -1367,6 +1392,33 @@ await CreateTestServerAsync( Assert.Equal("Hello, World!", ((TextContentBlock)result.Content[0]).Text); } + [Fact] + public async Task CallTool_StatelessTransport_ReturnsExpectedResult() + { + // arrange + var storage = new TestMcpStorage(); + await storage.AddOrUpdateToolAsync( + new OperationToolDefinition( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync( + "__resources__/GetBooksWithTitle1.graphql", + TestContext.Current.CancellationToken))), + TestContext.Current.CancellationToken); + var server = + await CreateTestServerAsync( + storage, + configureMcpServer: b => b.WithHttpTransport(o => o.Stateless = true)); + var mcpClient = await CreateMcpClientAsync(server.CreateClient()); + + // act + var result = await mcpClient.CallToolAsync( + "get_books_with_title1", + cancellationToken: TestContext.Current.CancellationToken); + + // assert + result.StructuredContent.MatchSnapshot(extension: ".json"); + } + [Fact] public async Task ReadResource_Valid_ReturnsResource() { diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/IntegrationTestBase.CallTool_StatelessTransport_ReturnsExpectedResult.json b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/IntegrationTestBase.CallTool_StatelessTransport_ReturnsExpectedResult.json new file mode 100644 index 00000000000..fc2c358aa00 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/__snapshots__/IntegrationTestBase.CallTool_StatelessTransport_ReturnsExpectedResult.json @@ -0,0 +1,9 @@ +{ + "data": { + "books": [ + { + "title": "Title" + } + ] + } +} From 1dec4fcabeb9299ebc49475c5a897c04506b9057 Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 10 Sep 2026 17:50:31 +0200 Subject: [PATCH 2/3] Bind the MCP schema services once from the root service provider --- .../Extensions/ServiceCollectionExtensions.cs | 12 ++++++- .../McpSchemaServiceProvider.cs | 12 +++++-- .../Adapters.Mcp.Tests/IntegrationTestBase.cs | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs index 0c497f5af8d..e20b0deb656 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/Extensions/ServiceCollectionExtensions.cs @@ -115,9 +115,19 @@ public static void AddMcpSchemaServices( var schemaServices = new McpSchemaServiceProvider(); + // The factory of a singleton is always invoked with the root service provider. + services.AddSingleton( + sp => + { + schemaServices.Bind(sp); + + return schemaServices; + }); + services .AddOptions() - .Configure((_, provider) => schemaServices.Bind(provider)); + .Configure( + (_, provider) => provider.GetRequiredService()); var mcpServerBuilder = services diff --git a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs index abe25eec58c..efb10e41abf 100644 --- a/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs +++ b/src/HotChocolate/Adapters/src/Adapters.Mcp.Core/McpSchemaServiceProvider.cs @@ -4,7 +4,7 @@ namespace HotChocolate.Adapters.Mcp; /// Provides the schema-scoped services that the MCP request handlers resolve from. /// /// -/// The provider is bound to the schema services when the MCP server options are built. +/// The provider is bound to the schema services the first time it is resolved from them. /// internal sealed class McpSchemaServiceProvider : IServiceProvider { @@ -17,5 +17,13 @@ public void Bind(IServiceProvider schemaServices) _schemaServices = schemaServices; } - public object? GetService(Type serviceType) => _schemaServices?.GetService(serviceType); + public object? GetService(Type serviceType) + { + if (_schemaServices is null) + { + throw new InvalidOperationException("The MCP schema services have not been bound."); + } + + return _schemaServices.GetService(serviceType); + } } diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs index 9218a113bc9..b8170f9084d 100644 --- a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/IntegrationTestBase.cs @@ -10,6 +10,7 @@ using HotChocolate.Types; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -1490,6 +1491,38 @@ public async Task ReadResource_Missing_ThrowsException() Assert.Equal("ui://views/missing.html", exception.Data["uri"]); } + [Fact] + public async Task ListTools_ServerOptionsMaterializedInScope_ReturnsTools() + { + // arrange + var storage = new TestMcpStorage(); + await storage.AddOrUpdateToolAsync( + new OperationToolDefinition( + Utf8GraphQLParser.Parse( + await File.ReadAllTextAsync( + "__resources__/GetBooksWithTitle1.graphql", + TestContext.Current.CancellationToken))), + TestContext.Current.CancellationToken); + var server = await CreateTestServerAsync(storage); + var mcpClient = await CreateMcpClientAsync(server.CreateClient()); + await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var executor = await server.Services.GetRequiredService().GetExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // arrange: materialize the server options in a scope that is disposed again + using (var scope = executor.Schema.Services.CreateScope()) + { + _ = scope.ServiceProvider + .GetRequiredService>().Value; + } + + // act + var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal("get_books_with_title1", Assert.Single(tools).Name); + } + [Fact] public async Task AddMcp_WithServerOption_SetsOption() { From cff2d1950d4d01ac41e3285733f82d3a82e2e2bb Mon Sep 17 00:00:00 2001 From: Glen Date: Thu, 10 Sep 2026 18:23:33 +0200 Subject: [PATCH 3/3] Add unit tests for the MCP schema service provider --- .../McpSchemaServiceProviderTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/McpSchemaServiceProviderTests.cs diff --git a/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/McpSchemaServiceProviderTests.cs b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/McpSchemaServiceProviderTests.cs new file mode 100644 index 00000000000..0f64a885b93 --- /dev/null +++ b/src/HotChocolate/Adapters/test/Adapters.Mcp.Tests/McpSchemaServiceProviderTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Adapters.Mcp; + +public sealed class McpSchemaServiceProviderTests +{ + [Fact] + public void GetService_NotBound_ThrowsInvalidOperationException() + { + // arrange + var schemaServices = new McpSchemaServiceProvider(); + + // act + void Act() => schemaServices.GetService(typeof(TestService)); + + // assert + var exception = Assert.Throws(Act); + Assert.Equal("The MCP schema services have not been bound.", exception.Message); + } + + [Fact] + public void GetService_Bound_ReturnsServiceFromBoundProvider() + { + // arrange + var expectedService = new TestService(); + using var serviceProvider = new ServiceCollection() + .AddSingleton(expectedService) + .BuildServiceProvider(); + var schemaServices = new McpSchemaServiceProvider(); + schemaServices.Bind(serviceProvider); + + // act + var service = schemaServices.GetService(typeof(TestService)); + + // assert + Assert.Same(expectedService, service); + } + + private sealed class TestService; +}