From b70bec885159563b2c535a06e426609531694987 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 22 Sep 2026 18:32:42 -0400 Subject: [PATCH 1/2] fix(mcp): let a prompt reach the capability services through its IServiceProvider McpServiceProviderOverlay layers IMcpSampling, IMcpClientRoots, IMcpElicitation and IMcpFeedback in front of the provider it wraps, but answered a request for IServiceProvider with that wrapped provider. A handler that declares IServiceProvider and resolves a capability from it therefore got a container that knew none of them: an explicitly registered prompt asking for IMcpSampling that way got null, on both mcp serve and the reusable BuildMcpServerOptions() path. The overlay now answers IServiceProvider with itself, as it already does for IServiceProviderIsService. The defect turned out narrower than #96's table. Declaring a capability service directly as a parameter already works: McpExplicitPrompt replaces request.Services with the session's overlay before the SDK binds the handler, which is also where the request is bound, the issue's third point. That case is pinned now too, since #96 reported it failing and it had no test. Making the overlay an IServiceScopeFactory, as the issue floated, would change nothing observable: McpExplicitPrompt discards the SDK's per-request scope either way. TDD: both IServiceProvider tests red first with "sp-null"; the direct-parameter test green on main from the start, measured before any change. Refs #96 --- src/Repl.Mcp/McpServiceProviderOverlay.cs | 7 ++- src/Repl.McpTests/Given_McpUserFeedback.cs | 65 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Repl.Mcp/McpServiceProviderOverlay.cs b/src/Repl.Mcp/McpServiceProviderOverlay.cs index 999b644..06b25b2 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; } diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index e589962..6ba803e 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1024,4 +1024,69 @@ 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 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); + + result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text).Should().ContainSingle() + .Which.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 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)); + } + } } From a71a11be0a8e07c0de831df2d6e18c69e60c901e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 22 Sep 2026 18:39:49 -0400 Subject: [PATCH 2/2] fix(mcp): keep the overlay's IsService in step with its GetService The overlay now answers IServiceProvider with itself, but IsService still asked the wrapped provider about it. The IServiceProviderIsService contract is that IsService reports what GetService can supply, so IsService now counts IServiceProvider and IServiceProviderIsService as well. Raised in review on #110, which predicted that a prompt declaring IServiceProvider would be classified as a client-supplied argument on ICoreReplApp.BuildMcpServerOptions() without an app provider, where the wrapped provider is empty. That did not reproduce: the new test for that path passed before this change, because the SDK binds an IServiceProvider parameter itself without asking IsService. The test stays as a guard for the path, and says so; it is not a reproduction. Refs #96 --- src/Repl.Mcp/McpServiceProviderOverlay.cs | 6 ++++- src/Repl.McpTests/Given_McpUserFeedback.cs | 29 +++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/Repl.Mcp/McpServiceProviderOverlay.cs b/src/Repl.Mcp/McpServiceProviderOverlay.cs index 06b25b2..2e19119 100644 --- a/src/Repl.Mcp/McpServiceProviderOverlay.cs +++ b/src/Repl.Mcp/McpServiceProviderOverlay.cs @@ -37,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 6ba803e..26a3f4a 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1051,6 +1051,28 @@ public async Task When_AReusableOptionsPromptResolvesACapabilityThroughItsServic 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) => { @@ -1071,15 +1093,10 @@ public async Task When_AReusableOptionsPromptResolvesACapabilityThroughItsServic await using (session.ConfigureAwait(false)) { var result = await session.Client.GetPromptAsync("probe", arguments: null).ConfigureAwait(false); - - result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text).Should().ContainSingle() - .Which.Should().Be("sp-ok"); + return string.Join('\n', result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); } } - private static readonly Func ResolveSamplingThroughProvider = - static services => services.GetService(typeof(IMcpSampling)) is null ? "sp-null" : "sp-ok"; - private static async Task GetPromptTextAsync(Delegate handler) { var session = await McpTestFixture.CreateAsync(_ => { }, options => options.Prompt("probe", handler)).ConfigureAwait(false);