Wrap each request in a service scope (#276)

* Wrap each request in a service scope

* Change request/notification delegates to return ValueTask

* Address feedback
This commit is contained in:
Stephen Toub 2025-04-11 19:19:12 -04:00 • committed by GitHub
commit f4c87f5e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 477 additions and 306 deletions

View file

@ -21,7 +21,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithListToolsHandler_Sets_Handler()
{
Func<RequestContext<ListToolsRequestParams>, CancellationToken, Task<ListToolsResult>> handler = (context, token) => Task.FromResult(new ListToolsResult());
Func<RequestContext<ListToolsRequestParams>, CancellationToken, ValueTask<ListToolsResult>> handler = async (context, token) => new ListToolsResult();
_builder.Object.WithListToolsHandler(handler);
@ -34,7 +34,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithCallToolHandler_Sets_Handler()
{
Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>> handler = (context, token) => Task.FromResult(new CallToolResponse());
Func<RequestContext<CallToolRequestParams>, CancellationToken, ValueTask<CallToolResponse>> handler = async (context, token) => new CallToolResponse();
_builder.Object.WithCallToolHandler(handler);
@ -47,7 +47,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithListPromptsHandler_Sets_Handler()
{
Func<RequestContext<ListPromptsRequestParams>, CancellationToken, Task<ListPromptsResult>> handler = (context, token) => Task.FromResult(new ListPromptsResult());
Func<RequestContext<ListPromptsRequestParams>, CancellationToken, ValueTask<ListPromptsResult>> handler = async (context, token) => new ListPromptsResult();
_builder.Object.WithListPromptsHandler(handler);
@ -60,7 +60,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithGetPromptHandler_Sets_Handler()
{
Func<RequestContext<GetPromptRequestParams>, CancellationToken, Task<GetPromptResult>> handler = (context, token) => Task.FromResult(new GetPromptResult());
Func<RequestContext<GetPromptRequestParams>, CancellationToken, ValueTask<GetPromptResult>> handler = async (context, token) => new GetPromptResult();
_builder.Object.WithGetPromptHandler(handler);
@ -73,7 +73,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithListResourceTemplatesHandler_Sets_Handler()
{
Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>> handler = (context, token) => Task.FromResult(new ListResourceTemplatesResult());
Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, ValueTask<ListResourceTemplatesResult>> handler = async (context, token) => new ListResourceTemplatesResult();
_builder.Object.WithListResourceTemplatesHandler(handler);
@ -86,7 +86,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithListResourcesHandler_Sets_Handler()
{
Func<RequestContext<ListResourcesRequestParams>, CancellationToken, Task<ListResourcesResult>> handler = (context, token) => Task.FromResult(new ListResourcesResult());
Func<RequestContext<ListResourcesRequestParams>, CancellationToken, ValueTask<ListResourcesResult>> handler = async (context, token) => new ListResourcesResult();
_builder.Object.WithListResourcesHandler(handler);
@ -99,7 +99,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithReadResourceHandler_Sets_Handler()
{
Func<RequestContext<ReadResourceRequestParams>, CancellationToken, Task<ReadResourceResult>> handler = (context, token) => Task.FromResult(new ReadResourceResult());
Func<RequestContext<ReadResourceRequestParams>, CancellationToken, ValueTask<ReadResourceResult>> handler = async (context, token) => new ReadResourceResult();
_builder.Object.WithReadResourceHandler(handler);
@ -112,7 +112,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithCompleteHandler_Sets_Handler()
{
Func<RequestContext<CompleteRequestParams>, CancellationToken, Task<CompleteResult>> handler = (context, token) => Task.FromResult(new CompleteResult());
Func<RequestContext<CompleteRequestParams>, CancellationToken, ValueTask<CompleteResult>> handler = async (context, token) => new CompleteResult();
_builder.Object.WithCompleteHandler(handler);
@ -125,7 +125,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithSubscribeToResourcesHandler_Sets_Handler()
{
Func<RequestContext<SubscribeRequestParams>, CancellationToken, Task<EmptyResult>> handler = (context, token) => Task.FromResult(new EmptyResult());
Func<RequestContext<SubscribeRequestParams>, CancellationToken, ValueTask<EmptyResult>> handler = async (context, token) => new EmptyResult();
_builder.Object.WithSubscribeToResourcesHandler(handler);
@ -138,7 +138,7 @@ public class McpServerBuilderExtensionsHandlerTests
[Fact]
public void WithUnsubscribeFromResourcesHandler_Sets_Handler()
{
Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, Task<EmptyResult>> handler = (context, token) => Task.FromResult(new EmptyResult());
Func<RequestContext<UnsubscribeRequestParams>, CancellationToken, ValueTask<EmptyResult>> handler = async (context, token) => new EmptyResult();
_builder.Object.WithUnsubscribeFromResourcesHandler(handler);

View file

@ -9,8 +9,6 @@ using System.ComponentModel;
using System.Text.Json.Serialization;
using System.Threading.Channels;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace ModelContextProtocol.Tests.Configuration;
public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBase
@ -144,7 +142,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
await using (client.RegisterNotificationHandler("notifications/prompts/list_changed", (notification, cancellationToken) =>
{
listChanged.Writer.TryWrite(notification);
return Task.CompletedTask;
return default;
}))
{
serverPrompts.Add(newPrompt);

View file

@ -16,8 +16,6 @@ using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace ModelContextProtocol.Tests.Configuration;
public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
@ -109,7 +107,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
throw new Exception($"Unknown tool '{request.Params?.Name}'");
}
})
.WithTools<EchoTool>(serializerOptions: JsonContext.Default.Options);
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options);
services.AddSingleton(new ObjectWithId());
}
@ -209,7 +207,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
{
listChanged.Writer.TryWrite(notification);
return Task.CompletedTask;
return default;
}))
{
serverTools.Add(newTool);
@ -431,8 +429,13 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[Fact]
public void Register_Tools_From_Current_Assembly()
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
return;
}
ServiceCollection sc = new();
sc.AddMcpServer().WithToolsFromAssembly(serializerOptions: JsonContext.Default.Options);
sc.AddMcpServer().WithToolsFromAssembly();
IServiceProvider services = sc.BuildServiceProvider();
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "Echo");
@ -448,7 +451,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
{
sc.AddSingleton(new ComplexObject());
}
sc.AddMcpServer().WithTools([typeof(EchoTool)], JsonContext.Default.Options);
sc.AddMcpServer().WithTools([typeof(EchoTool)], BuilderToolsJsonContext.Default.Options);
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = services.GetServices<McpServerTool>().First(t => t.ProtocolTool.Name == "EchoComplex");
@ -462,6 +465,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
}
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
@ -469,6 +473,11 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[InlineData(null)]
public void WithToolsFromAssembly_Parameters_Satisfiable_From_DI(ServiceLifetime? lifetime)
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
return;
}
ServiceCollection sc = new();
switch (lifetime)
{
@ -485,7 +494,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
break;
}
sc.AddMcpServer().WithToolsFromAssembly(serializerOptions: JsonContext.Default.Options);
sc.AddMcpServer().WithToolsFromAssembly();
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = services.GetServices<McpServerTool>().First(t => t.ProtocolTool.Name == "EchoComplex");
@ -528,9 +537,9 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
{
ServiceCollection sc = new();
sc.AddMcpServer()
.WithTools<EchoTool>(serializerOptions: JsonContext.Default.Options)
.WithTools<AnotherToolType>(serializerOptions: JsonContext.Default.Options)
.WithTools([typeof(ToolTypeWithNoAttribute)], JsonContext.Default.Options);
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options)
.WithTools<AnotherToolType>(serializerOptions: BuilderToolsJsonContext.Default.Options)
.WithTools([typeof(ToolTypeWithNoAttribute)], BuilderToolsJsonContext.Default.Options);
IServiceProvider services = sc.BuildServiceProvider();
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "double_echo");
@ -588,7 +597,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
{
ProgressNotification pn = JsonSerializer.Deserialize<ProgressNotification>(notification.Params, McpJsonUtilities.DefaultOptions)!;
notifications.Enqueue(pn);
return Task.CompletedTask;
return default;
}))
{
var result = await client.SendRequestAsync<CallToolRequestParams, CallToolResponse>(
@ -757,17 +766,17 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
public static string MethodD(string d) => d.ToString();
}
public class ObjectWithId
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
}
public class ComplexObject
{
public string? Name { get; set; }
public int Age { get; set; }
}
public class ObjectWithId
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
}
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(long))]
@ -778,5 +787,5 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(JsonElement))]
partial class JsonContext : JsonSerializerContext;
}
partial class BuilderToolsJsonContext : JsonSerializerContext;
}

View file

@ -0,0 +1,75 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Tests.Configuration;
public partial class McpServerScopedTests : ClientServerTestBase
{
public McpServerScopedTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithTools<EchoTool>(serializerOptions: McpServerScopedTestsJsonContext.Default.Options);
services.AddScoped(_ => new ComplexObject() { Name = "Scoped" });
}
[Fact]
public async Task InjectScopedServiceAsArgument()
{
IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(McpServerScopedTestsJsonContext.Default.Options, TestContext.Current.CancellationToken);
var tool = tools.First(t => t.Name == nameof(EchoTool.EchoComplex));
Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.JsonSchema, McpJsonUtilities.DefaultOptions));
int startingConstructed = ComplexObject.Constructed;
int startingDisposed = ComplexObject.Disposed;
for (int i = 1; i <= 10; i++)
{
Assert.Contains("\"Scoped\"", JsonSerializer.Serialize(await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken), McpJsonUtilities.DefaultOptions));
Assert.Equal(startingConstructed + i, ComplexObject.Constructed);
Assert.Equal(startingDisposed + i, ComplexObject.Disposed);
}
}
[McpServerToolType]
public sealed class EchoTool()
{
[McpServerTool]
public static string EchoComplex(ComplexObject complex) => complex.Name!;
}
public class ComplexObject : IAsyncDisposable
{
public static int Constructed;
public static int Disposed;
public ComplexObject()
{
Interlocked.Increment(ref Constructed);
}
public ValueTask DisposeAsync()
{
Interlocked.Increment(ref Disposed);
return default;
}
public string? Name { get; set; }
public int Age { get; set; }
}
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(JsonElement))]
partial class McpServerScopedTestsJsonContext : JsonSerializerContext;
}