diff --git a/src/Repl.Mcp/McpServiceProviderOverlay.cs b/src/Repl.Mcp/McpServiceProviderOverlay.cs index 999b644..2e19119 100644 --- a/src/Repl.Mcp/McpServiceProviderOverlay.cs +++ b/src/Repl.Mcp/McpServiceProviderOverlay.cs @@ -12,6 +12,11 @@ namespace Repl.Mcp; /// parameter is a dependency or a client-supplied argument, so without this a prompt declaring /// IMcpFeedback is classified as taking an argument named "feedback" and cannot be invoked at /// all. +/// +/// It answers with itself for the same reason. Handing back the provider +/// it wraps would give a caller that asks for the provider — a prompt handler declaring +/// , say — a container that has never heard of the services added here. +/// /// internal sealed class McpServiceProviderOverlay( IServiceProvider inner, @@ -19,7 +24,7 @@ internal sealed class McpServiceProviderOverlay( { public object? GetService(Type serviceType) { - if (serviceType == typeof(IServiceProviderIsService)) + if (serviceType == typeof(IServiceProviderIsService) || serviceType == typeof(IServiceProvider)) { return this; } @@ -32,7 +37,11 @@ internal sealed class McpServiceProviderOverlay( return inner.GetService(serviceType); } + // Kept in step with GetService, as the IServiceProviderIsService contract requires: what this overlay + // answers itself counts, whatever the wrapped provider says about it. public bool IsService(Type serviceType) => - overrides.ContainsKey(serviceType) + serviceType == typeof(IServiceProvider) + || serviceType == typeof(IServiceProviderIsService) + || overrides.ContainsKey(serviceType) || (inner.GetService(typeof(IServiceProviderIsService)) as IServiceProviderIsService)?.IsService(serviceType) == true; } diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index e589962..26a3f4a 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1024,4 +1024,86 @@ public async Task When_AHandlerReturnsAFailure_Then_ItsOwnTextStillReaches() because: "the handler authored that for whoever called it, and withholding it helps nobody"); } } + + [TestMethod] + [Description("An explicitly registered prompt can declare an MCP capability service as a parameter. Issue #96 reported this failing the prompts/get request outright; McpExplicitPrompt's replacement of request.Services already resolves it, and this pins that it stays so.")] + public async Task When_AnExplicitPromptInjectsSampling_Then_ItResolves() + { + var text = await GetPromptTextAsync(static (IMcpSampling sampling) => sampling is null ? "null" : "resolved") + .ConfigureAwait(false); + + text.Should().Be("resolved"); + } + + [TestMethod] + [Description("A prompt that declares IServiceProvider and resolves a capability service from it must find it. The overlay answered IServiceProvider with the provider it wraps, so a handler asking it for IServiceProvider got the inner container back, which knows nothing of IMcpSampling, IMcpClientRoots, IMcpElicitation or IMcpFeedback.")] + public async Task When_AnExplicitPromptResolvesACapabilityThroughItsServiceProvider_Then_ItIsFound() + { + var text = await GetPromptTextAsync(ResolveSamplingThroughProvider).ConfigureAwait(false); + + text.Should().Be("sp-ok"); + } + + [TestMethod] + [Description("The same as the mcp serve case above, on the reusable BuildMcpServerOptions() path, which issue #96 names as reproducing too: there the SDK dispatches straight into the pre-built prompt, with no Repl request handler in between.")] + public async Task When_AReusableOptionsPromptResolvesACapabilityThroughItsServiceProvider_Then_ItIsFound() + { + var app = ReplApp.Create(); + var mcpOptions = app.BuildMcpServerOptions(options => options.Prompt("probe", ResolveSamplingThroughProvider)); + + var text = await GetReusablePromptTextAsync(mcpOptions).ConfigureAwait(false); + + text.Should().Be("sp-ok"); + } + + [TestMethod] + [Description("The reusable path without an app provider: ICoreReplApp.BuildMcpServerOptions() wraps an empty provider that knows nothing, so the overlay alone has to supply both the capability service and IServiceProvider itself. Pins that a prompt declaring IServiceProvider still binds it as a dependency there. It passed before the overlay's IsService was aligned with its GetService — the SDK binds an IServiceProvider parameter itself, without asking IsService — so this is a guard for the path, not a reproduction.")] + public async Task When_APromptDeclaresIServiceProviderWithoutAnAppProvider_Then_ItIsBoundAsADependency() + { + var app = ReplApp.Create(); + var mcpOptions = app.Core.BuildMcpServerOptions(options => options.Prompt("probe", ResolveSamplingThroughProvider)); + + var text = await GetReusablePromptTextAsync(mcpOptions).ConfigureAwait(false); + + text.Should().Be("sp-ok"); + } + + private static readonly Func ResolveSamplingThroughProvider = + static services => services.GetService(typeof(IMcpSampling)) is null ? "sp-null" : "sp-ok"; + + private static async Task GetReusablePromptTextAsync(McpServerOptions mcpOptions) + { + var session = await McpPipeSession.StartAsync( + async (io, token) => + { + var transport = new StreamServerTransport(io.InputStream, io.OutputStream, "reusable-options-server"); + var server = McpServer.Create(transport, mcpOptions); + try + { + await server.RunAsync(token).ConfigureAwait(false); + } + finally + { + await server.DisposeAsync().ConfigureAwait(false); + await transport.DisposeAsync().ConfigureAwait(false); + } + }, + clientOptions: null, + CancellationToken.None).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.GetPromptAsync("probe", arguments: null).ConfigureAwait(false); + return string.Join('\n', result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); + } + } + + private static async Task GetPromptTextAsync(Delegate handler) + { + var session = await McpTestFixture.CreateAsync(_ => { }, options => options.Prompt("probe", handler)).ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.GetPromptAsync("probe", arguments: null).ConfigureAwait(false); + return string.Join('\n', result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); + } + } }