Enable injecting IMcpServer and friends into ctors (#570)

This commit is contained in:
Stephen Toub 2025-06-30 10:58:08 -04:00 • committed by GitHub
commit f3539cc2ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 249 additions and 191 deletions

View file

@ -2,6 +2,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
@ -57,8 +58,8 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
return Create(
AIFunctionFactory.Create(method, args =>
{
var request = (RequestContext<GetPromptRequestParams>)args.Context![typeof(RequestContext<GetPromptRequestParams>)]!;
return createTargetFunc(request);
Debug.Assert(args.Services is RequestServiceProvider<GetPromptRequestParams>, $"The service provider should be a {nameof(RequestServiceProvider<GetPromptRequestParams>)} for this method to work correctly.");
return createTargetFunc(((RequestServiceProvider<GetPromptRequestParams>)args.Services!).Request);
}, CreateAIFunctionFactoryOptions(method, options)),
options);
}
@ -74,54 +75,15 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
JsonSchemaCreateOptions = options?.SchemaCreateOptions,
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<GetPromptRequestParams>))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args),
};
}
if (pi.ParameterType == typeof(IMcpServer))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
};
}
if (pi.ParameterType == typeof(IProgress<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> to the progress token in the request,
// if there is one. If we can't get one, return a nop progress.
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
{
var requestContent = GetRequestContext(args);
if (requestContent?.Server is { } server &&
requestContent?.Params?.ProgressToken is { } progressToken)
{
return new TokenProgress(server, progressToken);
}
return NullProgress.Instance;
},
};
}
if (options?.Services is { } services &&
services.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType))
if (RequestServiceProvider<GetPromptRequestParams>.IsAugmentedWith(pi.ParameterType) ||
(options?.Services?.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType)))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
GetRequestContext(args)?.Services?.GetService(pi.ParameterType) ??
args.Services?.GetService(pi.ParameterType) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
@ -133,24 +95,13 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
(GetRequestContext(args)?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
}
return default;
static RequestContext<GetPromptRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.Context?.TryGetValue(typeof(RequestContext<GetPromptRequestParams>), out var orc) is true &&
orc is RequestContext<GetPromptRequestParams> requestContext)
{
return requestContext;
}
return null;
}
},
};
@ -226,14 +177,10 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
Throw.IfNull(request);
cancellationToken.ThrowIfCancellationRequested();
AIFunctionArguments arguments = new()
{
Services = request.Services,
Context = new Dictionary<object, object?>() { [typeof(RequestContext<GetPromptRequestParams>)] = request }
};
request.Services = new RequestServiceProvider<GetPromptRequestParams>(request, request.Services);
AIFunctionArguments arguments = new() { Services = request.Services };
var argDict = request.Params?.Arguments;
if (argDict is not null)
if (request.Params?.Arguments is { } argDict)
{
foreach (var kvp in argDict)
{

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Text;
@ -64,8 +65,8 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
return Create(
AIFunctionFactory.Create(method, args =>
{
var request = (RequestContext<ReadResourceRequestParams>)args.Context![typeof(RequestContext<ReadResourceRequestParams>)]!;
return createTargetFunc(request);
Debug.Assert(args.Services is RequestServiceProvider<ReadResourceRequestParams>, $"The service provider should be a {nameof(RequestServiceProvider<ReadResourceRequestParams>)} for this method to work correctly.");
return createTargetFunc(((RequestServiceProvider<ReadResourceRequestParams>)args.Services!).Request);
}, CreateAIFunctionFactoryOptions(method, options)),
options);
}
@ -81,54 +82,15 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
JsonSchemaCreateOptions = options?.SchemaCreateOptions,
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<ReadResourceRequestParams>))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args),
};
}
if (pi.ParameterType == typeof(IMcpServer))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
};
}
if (pi.ParameterType == typeof(IProgress<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> to the progress token in the request,
// if there is one. If we can't get one, return a nop progress.
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
{
var requestContent = GetRequestContext(args);
if (requestContent?.Server is { } server &&
requestContent?.Params?.ProgressToken is { } progressToken)
{
return new TokenProgress(server, progressToken);
}
return NullProgress.Instance;
},
};
}
if (options?.Services is { } services &&
services.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType))
if (RequestServiceProvider<ReadResourceRequestParams>.IsAugmentedWith(pi.ParameterType) ||
(options?.Services?.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType)))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
GetRequestContext(args)?.Services?.GetService(pi.ParameterType) ??
args.Services?.GetService(pi.ParameterType) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
@ -140,7 +102,7 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
(GetRequestContext(args)?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
@ -172,17 +134,6 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
}
return default;
static RequestContext<ReadResourceRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.Context?.TryGetValue(typeof(RequestContext<ReadResourceRequestParams>), out var rc) is true &&
rc is RequestContext<ReadResourceRequestParams> requestContext)
{
return requestContext;
}
return null;
}
},
};
@ -365,11 +316,8 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
}
// Build up the arguments for the AIFunction call, including all of the name/value pairs from the URI.
AIFunctionArguments arguments = new()
{
Services = request.Services,
Context = new Dictionary<object, object?>() { [typeof(RequestContext<ReadResourceRequestParams>)] = request }
};
request.Services = new RequestServiceProvider<ReadResourceRequestParams>(request, request.Services);
AIFunctionArguments arguments = new() { Services = request.Services };
// For templates, populate the arguments from the URI template.
if (match is not null)

View file

@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -64,8 +64,8 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
return Create(
AIFunctionFactory.Create(method, args =>
{
var request = (RequestContext<CallToolRequestParams>)args.Context![typeof(RequestContext<CallToolRequestParams>)]!;
return createTargetFunc(request);
Debug.Assert(args.Services is RequestServiceProvider<CallToolRequestParams>, $"The service provider should be a {nameof(RequestServiceProvider<CallToolRequestParams>)} for this method to work correctly.");
return createTargetFunc(((RequestServiceProvider<CallToolRequestParams>)args.Services!).Request);
}, CreateAIFunctionFactoryOptions(method, options)),
options);
}
@ -81,54 +81,15 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
JsonSchemaCreateOptions = options?.SchemaCreateOptions,
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<CallToolRequestParams>))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args),
};
}
if (pi.ParameterType == typeof(IMcpServer))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
};
}
if (pi.ParameterType == typeof(IProgress<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> to the progress token in the request,
// if there is one. If we can't get one, return a nop progress.
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
{
var requestContent = GetRequestContext(args);
if (requestContent?.Server is { } server &&
requestContent?.Params?.ProgressToken is { } progressToken)
{
return new TokenProgress(server, progressToken);
}
return NullProgress.Instance;
},
};
}
if (options?.Services is { } services &&
services.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType))
if (RequestServiceProvider<CallToolRequestParams>.IsAugmentedWith(pi.ParameterType) ||
(options?.Services?.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType)))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
GetRequestContext(args)?.Services?.GetService(pi.ParameterType) ??
args.Services?.GetService(pi.ParameterType) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
@ -140,24 +101,13 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
(GetRequestContext(args)?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
}
return default;
static RequestContext<CallToolRequestParams>? GetRequestContext(AIFunctionArguments args)
{
if (args.Context?.TryGetValue(typeof(RequestContext<CallToolRequestParams>), out var orc) is true &&
orc is RequestContext<CallToolRequestParams> requestContext)
{
return requestContext;
}
return null;
}
},
};
@ -260,14 +210,10 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
Throw.IfNull(request);
cancellationToken.ThrowIfCancellationRequested();
AIFunctionArguments arguments = new()
{
Services = request.Services,
Context = new Dictionary<object, object?>() { [typeof(RequestContext<CallToolRequestParams>)] = request }
};
request.Services = new RequestServiceProvider<CallToolRequestParams>(request, request.Services);
AIFunctionArguments arguments = new() { Services = request.Services };
var argDict = request.Params?.Arguments;
if (argDict is not null)
if (request.Params?.Arguments is { } argDict)
{
foreach (var kvp in argDict)
{

View file

@ -0,0 +1,58 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Server;
/// <summary>Augments a service provider with additional request-related services.</summary>
internal sealed class RequestServiceProvider<TRequestParams>(
RequestContext<TRequestParams> request, IServiceProvider? innerServices) :
IServiceProvider, IKeyedServiceProvider,
IServiceProviderIsService, IServiceProviderIsKeyedService,
IDisposable, IAsyncDisposable
where TRequestParams : RequestParams
{
/// <summary>Gets the request associated with this instance.</summary>
public RequestContext<TRequestParams> Request => request;
/// <summary>Gets whether the specified type is in the list of additional types this service provider wraps around the one in a provided request's services.</summary>
public static bool IsAugmentedWith(Type serviceType) =>
serviceType == typeof(RequestContext<TRequestParams>) ||
serviceType == typeof(IMcpServer) ||
serviceType == typeof(IProgress<ProgressNotificationValue>);
/// <inheritdoc />
public object? GetService(Type serviceType) =>
serviceType == typeof(RequestContext<TRequestParams>) ? request :
serviceType == typeof(IMcpServer) ? request.Server :
serviceType == typeof(IProgress<ProgressNotificationValue>) ?
(request.Params?.ProgressToken is { } progressToken ? new TokenProgress(request.Server, progressToken) : NullProgress.Instance) :
innerServices?.GetService(serviceType);
/// <inheritdoc />
public bool IsService(Type serviceType) =>
IsAugmentedWith(serviceType) ||
(innerServices as IServiceProviderIsService)?.IsService(serviceType) is true;
/// <inheritdoc />
public bool IsKeyedService(Type serviceType, object? serviceKey) =>
(serviceKey is null && IsService(serviceType)) ||
(innerServices as IServiceProviderIsKeyedService)?.IsKeyedService(serviceType, serviceKey) is true;
/// <inheritdoc />
public object? GetKeyedService(Type serviceType, object? serviceKey) =>
serviceKey is null ? GetService(serviceType) :
(innerServices as IKeyedServiceProvider)?.GetKeyedService(serviceType, serviceKey);
/// <inheritdoc />
public object GetRequiredKeyedService(Type serviceType, object? serviceKey) =>
GetKeyedService(serviceType, serviceKey) ??
throw new InvalidOperationException($"No service of type '{serviceType}' with key '{serviceKey}' is registered.");
/// <inheritdoc />
public void Dispose() =>
(innerServices as IDisposable)?.Dispose();
/// <inheritdoc />
public ValueTask DisposeAsync() =>
innerServices is IAsyncDisposable asyncDisposable ? asyncDisposable.DisposeAsync() : default;
}

View file

@ -1,9 +1,11 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -44,6 +46,58 @@ public class McpServerPromptTests
Assert.Equal("Hello", Assert.IsType<TextContentBlock>(result.Messages[0].Content).Text);
}
[Fact]
public async Task SupportsCtorInjection()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
Mock<IMcpServer> mockServer = new();
mockServer.SetupGet(s => s.Services).Returns(services);
MethodInfo? testMethod = typeof(HasCtorWithSpecialParameters).GetMethod(nameof(HasCtorWithSpecialParameters.TestPrompt));
Assert.NotNull(testMethod);
McpServerPrompt prompt = McpServerPrompt.Create(testMethod, r =>
{
Assert.NotNull(r.Services);
return ActivatorUtilities.CreateInstance(r.Services, typeof(HasCtorWithSpecialParameters));
}, new() { Services = services });
var result = await prompt.GetAsync(
new RequestContext<GetPromptRequestParams>(mockServer.Object),
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Messages);
Assert.Single(result.Messages);
Assert.Equal("True True True True", Assert.IsType<TextContentBlock>(result.Messages[0].Content).Text);
}
private sealed class HasCtorWithSpecialParameters
{
private readonly MyService _ms;
private readonly IMcpServer _server;
private readonly RequestContext<GetPromptRequestParams> _request;
private readonly IProgress<ProgressNotificationValue> _progress;
public HasCtorWithSpecialParameters(MyService ms, IMcpServer server, RequestContext<GetPromptRequestParams> request, IProgress<ProgressNotificationValue> progress)
{
Assert.NotNull(ms);
Assert.NotNull(server);
Assert.NotNull(request);
Assert.NotNull(progress);
_ms = ms;
_server = server;
_request = request;
_progress = progress;
}
public string TestPrompt() => $"{_ms is not null} {_server is not null} {_request is not null} {_progress is not null}";
}
[Fact]
public async Task SupportsServiceFromDI()
{

View file

@ -360,6 +360,59 @@ public partial class McpServerResourceTests
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task SupportsCtorInjection()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
Mock<IMcpServer> mockServer = new();
mockServer.SetupGet(s => s.Services).Returns(services);
MethodInfo? testMethod = typeof(HasCtorWithSpecialParameters).GetMethod(nameof(HasCtorWithSpecialParameters.TestResource));
Assert.NotNull(testMethod);
McpServerResource tool = McpServerResource.Create(testMethod, r =>
{
Assert.NotNull(r.Services);
return ActivatorUtilities.CreateInstance(r.Services, typeof(HasCtorWithSpecialParameters));
}, new() { Services = services });
var result = await tool.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "https://something" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Contents);
Assert.Single(result.Contents);
Assert.Equal("True True True True", Assert.IsType<TextResourceContents>(result.Contents[0]).Text);
}
private sealed class HasCtorWithSpecialParameters
{
private readonly MyService _ms;
private readonly IMcpServer _server;
private readonly RequestContext<ReadResourceRequestParams> _request;
private readonly IProgress<ProgressNotificationValue> _progress;
public HasCtorWithSpecialParameters(MyService ms, IMcpServer server, RequestContext<ReadResourceRequestParams> request, IProgress<ProgressNotificationValue> progress)
{
Assert.NotNull(ms);
Assert.NotNull(server);
Assert.NotNull(request);
Assert.NotNull(progress);
_ms = ms;
_server = server;
_request = request;
_progress = progress;
}
[McpServerResource(UriTemplate = "https://something")]
public string TestResource() => $"{_ms is not null} {_server is not null} {_request is not null} {_progress is not null}";
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]

View file

@ -50,6 +50,58 @@ public partial class McpServerToolTests
Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsCtorInjection()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
Mock<IMcpServer> mockServer = new();
mockServer.SetupGet(s => s.Services).Returns(services);
MethodInfo? testMethod = typeof(HasCtorWithSpecialParameters).GetMethod(nameof(HasCtorWithSpecialParameters.TestTool));
Assert.NotNull(testMethod);
McpServerTool tool = McpServerTool.Create(testMethod, r =>
{
Assert.NotNull(r.Services);
return ActivatorUtilities.CreateInstance(r.Services, typeof(HasCtorWithSpecialParameters));
}, new() { Services = services });
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object),
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.Single(result.Content);
Assert.Equal("True True True True", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
private sealed class HasCtorWithSpecialParameters
{
private readonly MyService _ms;
private readonly IMcpServer _server;
private readonly RequestContext<CallToolRequestParams> _request;
private readonly IProgress<ProgressNotificationValue> _progress;
public HasCtorWithSpecialParameters(MyService ms, IMcpServer server, RequestContext<CallToolRequestParams> request, IProgress<ProgressNotificationValue> progress)
{
Assert.NotNull(ms);
Assert.NotNull(server);
Assert.NotNull(request);
Assert.NotNull(progress);
_ms = ms;
_server = server;
_request = request;
_progress = progress;
}
public string TestTool() => $"{_ms is not null} {_server is not null} {_request is not null} {_progress is not null}";
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]