Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ public static void AddMcpSchemaServices(
var mcpServers = new ConcurrentDictionary<string, McpServer>();
services.AddSingleton(mcpServers);

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<McpServerOptions>()
.Configure<IServiceProvider>(
(_, provider) => provider.GetRequiredService<McpSchemaServiceProvider>());

var mcpServerBuilder =
services
.AddMcpServer(options =>
Expand Down Expand Up @@ -151,14 +167,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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ internal static class CallToolHandler
#endif
public static async ValueTask<CallToolResult> HandleAsync(
RequestContext<CallToolRequestParams> context,
IServiceProvider schemaServices,
CancellationToken cancellationToken)
{
var services = context.Services!;
var registry = services.GetRequiredService<McpFeatureRegistry>();
var registry = schemaServices.GetRequiredService<McpFeatureRegistry>();

if (!registry.TryGetTool(context.Params!.Name, out var tool))
{
Expand Down Expand Up @@ -59,8 +59,9 @@ public static async ValueTask<CallToolResult> HandleAsync(
};
}

var requestExecutor = services.GetRequiredService<IRequestExecutor>();
var rootServiceProvider = services.GetRequiredService<IRootServiceProviderAccessor>().ServiceProvider;
var requestExecutor = schemaServices.GetRequiredService<IRequestExecutor>();
var rootServiceProvider =
schemaServices.GetRequiredService<IRootServiceProviderAccessor>().ServiceProvider;
var httpContext = rootServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
var diagnosticEvents = requestExecutor.Schema.Services.GetRequiredService<IServerDiagnosticEvents>();
var requestBuilder = CreateRequestBuilder(httpContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ namespace HotChocolate.Adapters.Mcp.Handlers;

internal static partial class GetPromptHandler
{
public static GetPromptResult Handle(RequestContext<GetPromptRequestParams> context)
public static GetPromptResult Handle(
RequestContext<GetPromptRequestParams> context,
IServiceProvider schemaServices)
{
var registry = context.Services!.GetRequiredService<McpFeatureRegistry>();
var registry = schemaServices.GetRequiredService<McpFeatureRegistry>();

if (!registry.TryGetPrompt(context.Params!.Name, out var prompt))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ListPromptsRequestParams> context)
public static ListPromptsResult Handle(IServiceProvider schemaServices)
{
var registry = context.Services!.GetRequiredService<McpFeatureRegistry>();
var registry = schemaServices.GetRequiredService<McpFeatureRegistry>();

return new ListPromptsResult
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ListToolsRequestParams> context)
public static ListToolsResult Handle(IServiceProvider schemaServices)
{
var registry = context.Services!.GetRequiredService<McpFeatureRegistry>();
var registry = schemaServices.GetRequiredService<McpFeatureRegistry>();

return new ListToolsResult
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ namespace HotChocolate.Adapters.Mcp.Handlers;

internal static class ReadResourceHandler
{
public static ReadResourceResult Handle(RequestContext<ReadResourceRequestParams> context)
public static ReadResourceResult Handle(
RequestContext<ReadResourceRequestParams> context,
IServiceProvider schemaServices)
{
var registry = context.Services!.GetRequiredService<McpFeatureRegistry>();
var registry = schemaServices.GetRequiredService<McpFeatureRegistry>();

if (!registry.TryGetToolByViewResourceUri(context.Params!.Uri, out var tool))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace HotChocolate.Adapters.Mcp;

/// <summary>
/// Provides the schema-scoped services that the MCP request handlers resolve from.
/// </summary>
/// <remarks>
/// The provider is bound to the schema services the first time it is resolved from them.
/// </remarks>
internal sealed class McpSchemaServiceProvider : IServiceProvider
{
private IServiceProvider? _schemaServices;

public void Bind(IServiceProvider schemaServices)
{
ArgumentNullException.ThrowIfNull(schemaServices);

_schemaServices = schemaServices;
}

public object? GetService(Type serviceType)
{
if (_schemaServices is null)
{
throw new InvalidOperationException("The MCP schema services have not been bound.");
}

return _schemaServices.GetService(serviceType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ 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);
var textContentBlock = Assert.IsType<TextContentBlock>(result.Content[0]);
Assert.Equal("The tool 'unknown' was not found.", textContentBlock.Text);
}

private static async Task<RequestContext<CallToolRequestParams>> CreateRequestContextAsync(
string toolName)
private static async Task<(RequestContext<CallToolRequestParams>, IServiceProvider)>
CreateRequestContextAsync(string toolName)
{
var storage = new TestMcpStorage();
await storage.AddOrUpdateToolAsync(
Expand All @@ -49,15 +52,16 @@ await storage.AddOrUpdateToolAsync(
var executorProvider = serviceProvider.GetRequiredService<IRequestExecutorProvider>();
var executor = await executorProvider.GetExecutorAsync();
Mock<McpServer> mockServer = new();
mockServer.SetupGet(s => s.Services).Returns(executor.Schema.Services);
var request = new JsonRpcRequest { Method = RequestMethods.ToolsCall };

return new RequestContext<CallToolRequestParams>(
mockServer.Object,
request,
new CallToolRequestParams
{
Name = toolName
});
return (
new RequestContext<CallToolRequestParams>(
mockServer.Object,
request,
new CallToolRequestParams
{
Name = toolName
}),
executor.Schema.Services);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -660,6 +661,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()
{
Expand Down Expand Up @@ -1367,6 +1393,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()
{
Expand Down Expand Up @@ -1438,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<IRequestExecutorProvider>().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<IOptionsSnapshot<McpServerOptions>>().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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"data": {
"books": [
{
"title": "Title"
}
]
}
}
Loading