From d5af58cec170a8a5c8c2c1fe3d5a3e8fa22fba07 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 13:55:27 -0400 Subject: [PATCH 01/15] feat(core): report how a run ended, and mark what it called into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions the MCP host needs, and neither is MCP-specific. A sub-invocation reported only an exit code. A host that surfaces a failure to somebody other than the operator also needs to know how the run ended — a handler that returned a failure authored that text for its caller, while a handler that threw did not — so RunSubInvocationWithOutcomeAsync reports the outcome kind and the exception alongside the code. The kind alone is not enough. The binder's own diagnostics and an exception escaping application code are both binding failures and both commonly InvalidOperationException, yet the first is meant to be read and the second can name a path or a connection string. Every call the binder makes into application code — the container, an options-group constructor, a property setter — is now marked as ReplBindingCallbackException, which is the only place that distinction is still knowable. Module presence can also be decided from a provider of its own. A host that publishes a command catalog decided presence once, when it published; re-deciding it at execution from a different view makes an advertised command unreachable. The parameter is optional and defaults to today's behaviour, so nothing that does not ask for it changes. Cancellation is told apart by who asked for it rather than by the exception's type, so a factory that runs its own budget and gives up is marked like any other failure, while the caller withdrawing is not. The marker is a wrapper, and a local surface still renders the cause: it names only what could not be supplied, and an operator debugging a registration came for why. A host publishing to somebody else decides by the exception's type, not by that text, so both readers get what they need. --- src/Repl.Core/CommandAnnotations.cs | 5 +- src/Repl.Core/CoreReplApp.Execution.cs | 66 +++++++-- src/Repl.Core/CoreReplApp.cs | 52 +++++++- src/Repl.Core/ISubInvocableReplApp.cs | 16 +++ .../Parsing/HandlerArgumentBinder.cs | 125 ++++++++++++++++-- src/Repl.Core/ReplBindingCallbackException.cs | 24 ++++ src/Repl.Core/SubInvocationOutcome.cs | 23 ++++ src/Repl.Tests/Given_ExitCodes.cs | 20 +++ 8 files changed, 307 insertions(+), 24 deletions(-) create mode 100644 src/Repl.Core/ReplBindingCallbackException.cs create mode 100644 src/Repl.Core/SubInvocationOutcome.cs diff --git a/src/Repl.Core/CommandAnnotations.cs b/src/Repl.Core/CommandAnnotations.cs index 9b7a5dbf..61c54d23 100644 --- a/src/Repl.Core/CommandAnnotations.cs +++ b/src/Repl.Core/CommandAnnotations.cs @@ -31,8 +31,9 @@ public sealed record CommandAnnotations public bool OpenWorld { get; init; } /// - /// Indicates the command may take a long time to complete. - /// Enables task-based execution in programmatic clients. + /// Indicates the command may take a long time to complete, so programmatic clients + /// should expect a slow call. Protocol-level task-based execution (MCP Tasks) is not + /// advertised until Repl integrates the SDK's Tasks extension (issue #72). /// public bool LongRunning { get; init; } diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index ce67001e..b2e7adc6 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -59,6 +59,26 @@ ValueTask ISubInvocableReplApp.RunSubInvocationAsync( CancellationToken cancellationToken) => RunSubInvocationAsync(args, serviceProvider, cancellationToken); + async ValueTask ISubInvocableReplApp.RunSubInvocationWithOutcomeAsync( + string[] args, + IServiceProvider serviceProvider, + IServiceProvider? presenceServiceProvider, + CancellationToken cancellationToken) + { + var outcome = await RunUnderCancellationPolicyAsync( + args, + serviceProvider, + isSubInvocation: true, + cancellationToken, + presenceServiceProvider) + .ConfigureAwait(false); + + return new SubInvocationOutcome( + ResolveProcessExitCode(outcome, isSubInvocation: true), + outcome.Kind, + outcome.Exception); + } + private async ValueTask ExecuteCoreAsync( IReadOnlyList args, IServiceProvider serviceProvider, @@ -107,11 +127,26 @@ internal ValueTask RunOutcomeWithServicesAsync( return ExecutionOutcome.Cancelled(new OperationCanceledException(cancellationToken)); } + /// + /// What a failure says to the operator running this process. + /// + /// + /// is a wrapper whose own message names only what could + /// not be supplied; the cause is inside it. Marking the failure exists so a host publishing to + /// somebody other than the operator can withhold that cause — it decides by the exception's type, not + /// by this text — and here the reader is the operator, who came for exactly that cause. + /// + private static string DescribeLocally(Exception exception) => + exception is ReplBindingCallbackException { InnerException: { } cause } + ? cause.Message + : exception.Message; + private async ValueTask RunUnderCancellationPolicyAsync( IReadOnlyList args, IServiceProvider serviceProvider, bool isSubInvocation, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IServiceProvider? presenceServiceProvider = null) { _options.Interaction.SetObserver(observer: ExecutionObserver); try @@ -120,7 +155,12 @@ private async ValueTask RunUnderCancellationPolicyAsync( { // Inside the try so a token cancelled before the run follows the same Cancelled policy. cancellationToken.ThrowIfCancellationRequested(); - return await ExecuteCoreOutcomeAsync(args, serviceProvider, isSubInvocation, cancellationToken) + return await ExecuteCoreOutcomeAsync( + args, + serviceProvider, + isSubInvocation, + cancellationToken, + presenceServiceProvider) .ConfigureAwait(false); } catch (OperationCanceledException ex) when (IsConvertibleCancellation(isSubInvocation, cancellationToken)) @@ -156,7 +196,8 @@ private async ValueTask ExecuteCoreOutcomeAsync( IReadOnlyList args, IServiceProvider serviceProvider, bool isSubInvocation, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IServiceProvider? presenceServiceProvider = null) { if (ReplSessionIO.IsProgrammatic && !ReplSessionIO.HasCurrentProgrammaticInvocationContract) { @@ -179,7 +220,12 @@ private async ValueTask ExecuteCoreOutcomeAsync( return globalDiagnostics; } - return await ExecuteParsedCoreAsync(globalOptions, serviceProvider, isSubInvocation, cancellationToken) + return await ExecuteParsedCoreAsync( + globalOptions, + serviceProvider, + isSubInvocation, + cancellationToken, + presenceServiceProvider) .ConfigureAwait(false); } @@ -273,14 +319,18 @@ private async ValueTask ExecuteParsedCoreAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, bool isSubInvocation, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IServiceProvider? presenceServiceProvider = null) { _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations if (!isSubInvocation) { _globalOptionsSnapshot.SetSessionBaseline(); } - using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); + using var runtimeStateScope = PushRuntimeState( + serviceProvider, + isInteractiveSession: false, + presenceServiceProvider); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; var ambiguousOutcome = await TryHandleAmbiguousPrefixAsync( @@ -838,7 +888,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma // keys on `bound`, so a service factory that cancels before binding completes is a // BindingError. The interactive loop keeps its own Ctrl+C semantics. return (await RenderFailureAsync( - Results.Error("execution_error", ex.Message), ex, bound, globalOptions, serviceProvider, cancellationToken) + Results.Error("execution_error", DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false), false); } catch (OperationCanceledException) @@ -849,7 +899,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma catch (InvalidOperationException ex) { return (await RenderFailureAsync( - Results.Validation(ex.Message), ex, bound, globalOptions, serviceProvider, cancellationToken) + Results.Validation(DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false), false); } catch (Exception ex) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index e998ac74..e6916f9d 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -608,7 +608,8 @@ private ReplRuntimeChannel ResolveCurrentRuntimeChannel() internal ActiveRoutingGraph ResolveActiveRoutingGraph(bool useDurableCache) { var runtime = _runtimeState.Value; - var serviceProvider = runtime?.ServiceProvider ?? _services; + // Presence decides the graph, so it is what the cache is keyed on and what the predicates see. + var serviceProvider = runtime?.PresenceServiceProvider ?? runtime?.ServiceProvider ?? _services; var channel = ResolveCurrentRuntimeChannel(); var cacheVersion = Interlocked.Read(ref _routingCacheVersion); var cacheBucket = _routingCacheByServiceProvider.GetOrCreateValue(serviceProvider); @@ -666,6 +667,33 @@ private HashSet ResolveActiveModuleIds(ModulePresenceContext context) return active; } + /// + /// Whether any route was ever registered that satisfies , whatever its + /// module's presence predicate would decide and whether or not a later registration shadows it. + /// + /// + /// For a caller that must answer what the application can contain rather than what one + /// resolution does contain — declaring an optional protocol capability, for instance, which happens + /// once and cannot be revised per caller. Blind to presence, because an answer derived from one + /// evaluation of the predicates is only as good as that evaluation's inputs and a predicate can + /// depend on state the caller does not have; blind to shadowing, because a template registered + /// twice resolves to a single route while the shadowed registration is still reachable from any + /// resolution that excludes the shadowing module. Yields a verdict rather than the routes so that + /// no caller can project command names through it, and resolves, documents and validates nothing. + /// + internal bool AnyRegisteredRoute(Func predicate) + { + foreach (var route in _routes) + { + if (predicate(route)) + { + return true; + } + } + + return false; + } + private RouteDefinition[] ResolveActiveRoutes(HashSet activeModuleIds) { var routesByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -715,10 +743,16 @@ private ContextDefinition[] ResolveActiveContexts(HashSet activeModuleIds) /// internal bool IsInteractiveSession => _runtimeState.Value?.IsInteractiveSession == true; - internal RuntimeStateScope PushRuntimeState(IServiceProvider serviceProvider, bool isInteractiveSession) + internal RuntimeStateScope PushRuntimeState( + IServiceProvider serviceProvider, + bool isInteractiveSession, + IServiceProvider? presenceServiceProvider = null) { var previous = _runtimeState.Value; - _runtimeState.Value = new InvocationRuntimeState(serviceProvider, isInteractiveSession); + _runtimeState.Value = new InvocationRuntimeState( + serviceProvider, + isInteractiveSession, + presenceServiceProvider); return new RuntimeStateScope(_runtimeState, previous); } @@ -880,9 +914,19 @@ private readonly record struct ModuleRegistration( int ModuleId, Func IsPresent); + /// Resolves handler arguments for this invocation. + /// Whether the invocation belongs to an interactive session. + /// + /// Resolves module presence predicates, when they must be decided from something other than what + /// binds the handler. A host that publishes a command catalog has to keep the two apart: what it + /// advertised was decided from one view of the world, and re-deciding at execution from another + /// makes an advertised command unreachable. — the default everywhere except + /// that case — means presence and binding share one provider, as they always have. + /// internal readonly record struct InvocationRuntimeState( IServiceProvider ServiceProvider, - bool IsInteractiveSession); + bool IsInteractiveSession, + IServiceProvider? PresenceServiceProvider = null); private sealed class RoutingCacheEntry(long version, ActiveRoutingGraph graph) { diff --git a/src/Repl.Core/ISubInvocableReplApp.cs b/src/Repl.Core/ISubInvocableReplApp.cs index 6846e1ca..b9807437 100644 --- a/src/Repl.Core/ISubInvocableReplApp.cs +++ b/src/Repl.Core/ISubInvocableReplApp.cs @@ -6,4 +6,20 @@ ValueTask RunSubInvocationAsync( string[] args, IServiceProvider serviceProvider, CancellationToken cancellationToken = default); + + /// + /// As , and also reports how the run ended. + /// + /// Command-line tokens for the sub-invocation. + /// Resolves handler arguments. + /// Cancels the run. + /// + /// Decides module presence, when that must not be decided from — + /// a host that already published a catalog has to run the command the catalog promised. + /// + ValueTask RunSubInvocationWithOutcomeAsync( + string[] args, + IServiceProvider serviceProvider, + IServiceProvider? presenceServiceProvider = null, + CancellationToken cancellationToken = default); } diff --git a/src/Repl.Core/Parsing/HandlerArgumentBinder.cs b/src/Repl.Core/Parsing/HandlerArgumentBinder.cs index 91d6e7c5..07590a0a 100644 --- a/src/Repl.Core/Parsing/HandlerArgumentBinder.cs +++ b/src/Repl.Core/Parsing/HandlerArgumentBinder.cs @@ -55,7 +55,11 @@ internal static class HandlerArgumentBinder if (context.ImplicitServiceParameters.TryGetGlobalOptionsServiceType(parameter.ParameterType, out var globalOptionsServiceType)) { - var globalOptions = context.ServiceProvider.GetService(globalOptionsServiceType); + var globalOptions = Activate( + context.ServiceProvider, + globalOptionsServiceType, + parameter.Name ?? "?", + context.CancellationToken); if (globalOptions is not null) { return globalOptions; @@ -213,7 +217,12 @@ private static bool TryResolveFromContextOrServices( if (hasFromServices) { - return ResolveExplicitFromServices(parameter, context.ServiceProvider, fromServices!, out resolved); + return ResolveExplicitFromServices( + parameter, + context.ServiceProvider, + fromServices!, + context.CancellationToken, + out resolved); } return ResolveImplicitFromContextOrServices(parameter, context, skipContext, out resolved); @@ -249,9 +258,10 @@ private static bool ResolveExplicitFromServices( System.Reflection.ParameterInfo parameter, IServiceProvider serviceProvider, FromServicesAttribute fromServices, + CancellationToken cancellationToken, out object? resolved) { - resolved = ResolveService(parameter.ParameterType, serviceProvider, fromServices.Key); + resolved = ResolveService(parameter.ParameterType, serviceProvider, fromServices.Key, cancellationToken); if (resolved is not null) { return true; @@ -278,7 +288,11 @@ private static bool ResolveImplicitFromContextOrServices( object? contextValue = null; var foundContext = !skipContext && TryResolveFromContext(parameter.ParameterType, context.ContextValues, out contextValue); - var serviceValue = context.ServiceProvider.GetService(parameter.ParameterType); + var serviceValue = Activate( + context.ServiceProvider, + parameter.ParameterType, + parameter.Name ?? "?", + context.CancellationToken); if (foundContext && serviceValue is not null) { throw new InvalidOperationException( @@ -320,16 +334,107 @@ private static bool TryResolveAllFromContext( return true; } - private static object? ResolveService(Type parameterType, IServiceProvider serviceProvider, string? key) + private static object? ResolveService( + Type parameterType, + IServiceProvider serviceProvider, + string? key, + CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(key)) { - return serviceProvider.GetService(parameterType); + return Activate(serviceProvider, parameterType, parameterType.Name, cancellationToken); + } + + try + { + return TryGetKeyedService(serviceProvider, parameterType, key); + } + catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken)) + { + throw new ReplBindingCallbackException(parameterType.Name, exception); + } + } + + /// Constructs an option group, marking anything its constructor raises. + private static object CreateGroupInstance( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type groupType, + CancellationToken cancellationToken) + { + try + { + return Activator.CreateInstance(groupType)!; + } + catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken)) + { + throw new ReplBindingCallbackException(groupType.Name, exception); } + } + + /// + /// Assigns one option-group property, marking anything its setter raises. + /// + /// + /// A setter is application code as much as a service factory is, and reaches the pipeline as the + /// same unmarked binding failure. It can expose the same paths and application state. + /// + private static void AssignProperty( + PropertyInfo property, + object instance, + object? value, + CancellationToken cancellationToken) + { + try + { + property.SetValue(instance, value); + } + catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken)) + { + throw new ReplBindingCallbackException(property.Name, exception); + } + } - return TryGetKeyedService(serviceProvider, parameterType, key); + /// + /// Resolves a service, reporting what the container raises as application code failing rather than + /// letting it pass for a diagnostic the binder wrote itself. + /// + /// + /// The two are indistinguishable once they reach the pipeline — same outcome kind, commonly the same + /// exception type — and they deserve opposite treatment: the binder's own message explains what the + /// caller got wrong, while a factory's can name a path or a connection string. Marking the failure + /// here is what lets a host publishing to a remote caller keep one and withhold the other. Every + /// call the binder makes into application code is marked the same way; a service factory was simply + /// the first one found. + /// + private static object? Activate( + IServiceProvider serviceProvider, + Type serviceType, + string parameterName, + CancellationToken cancellationToken) + { + try + { + return serviceProvider.GetService(serviceType); + } + catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken)) + { + throw new ReplBindingCallbackException(parameterName, exception); + } } + /// + /// Whether is application code failing rather than the caller + /// withdrawing. + /// + /// + /// Cancellation is told apart by who asked for it, not by the exception's type. A factory that runs + /// its own budget and gives up has failed like any other, and its message deserves the same + /// treatment; only the caller abandoning the run is a withdrawal, and that one is not ours to + /// relabel. + /// + private static bool IsApplicationFailure(Exception exception, CancellationToken cancellationToken) => + exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested; + [UnconditionalSuppressMessage( "Trimming", "IL2075", @@ -602,7 +707,7 @@ private static object BindOptionsGroup( InvocationBindingContext context, ref int positionalIndex) { - var instance = Activator.CreateInstance(groupType)!; + var instance = CreateGroupInstance(groupType, context.CancellationToken); foreach (var property in groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { @@ -631,7 +736,7 @@ private static object BindOptionsGroup( property.PropertyType, context.NumericFormatProvider, enumIgnoreCase); - property.SetValue(instance, converted); + AssignProperty(property, instance, converted, context.CancellationToken); continue; } @@ -649,7 +754,7 @@ private static object BindOptionsGroup( // Same positional upper-bound parity as the handler-parameter path. ThrowIfExplicitUpperBoundExceeded( context.OptionSchema, propertyName, positionalIndex - positionalStart); - property.SetValue(instance, positionalValue); + AssignProperty(property, instance, positionalValue, context.CancellationToken); continue; } } diff --git a/src/Repl.Core/ReplBindingCallbackException.cs b/src/Repl.Core/ReplBindingCallbackException.cs new file mode 100644 index 00000000..fff0e5c5 --- /dev/null +++ b/src/Repl.Core/ReplBindingCallbackException.cs @@ -0,0 +1,24 @@ +namespace Repl; + +/// +/// Thrown when application code the binder invoked while supplying a handler argument raised. The +/// original failure is the inner exception. +/// +/// +/// This exists to tell two binding failures apart that are otherwise identical: a diagnostic the +/// binder wrote for the caller ("cannot convert 'abc' to an int") and an exception that escaped +/// application code — a service factory, an options-group constructor, a property setter. Both surface +/// as a binding failure and both are commonly an , so neither +/// the outcome kind nor the exception type distinguishes them — yet the first is meant to be read and +/// the second can name a filesystem path, a connection string, or application state. A host that +/// publishes failures to someone other than the operator uses this type to withhold the second while +/// keeping the first; a local console keeps the inner cause, where the reader is the operator. +/// +/// What the binder was supplying — a parameter or a property. +/// Failure raised by the application code. +public sealed class ReplBindingCallbackException(string target, Exception innerException) + : InvalidOperationException($"Supplying '{target}' failed.", innerException) +{ + /// Gets what the binder was supplying when the application code raised. + public string Target { get; } = target; +} diff --git a/src/Repl.Core/SubInvocationOutcome.cs b/src/Repl.Core/SubInvocationOutcome.cs new file mode 100644 index 00000000..4bcc56bd --- /dev/null +++ b/src/Repl.Core/SubInvocationOutcome.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace Repl; + +/// +/// A sub-invocation's exit code together with how the run ended. +/// +/// +/// A host that surfaces a failure to somebody other than the operator needs to know whether the text +/// it is about to show was authored by the handler or rendered from an exception it never meant to +/// report. The exit code alone cannot tell those apart. +/// +/// Resolved process exit code. +/// How the run ended. +/// +/// Exception that ended the run, when one did. The kind says what happened; only the exception says +/// where it came from, and a host deciding what a remote caller may read needs both. +/// +[StructLayout(LayoutKind.Auto)] +internal readonly record struct SubInvocationOutcome( + int ExitCode, + ReplExecutionOutcomeKind Kind, + Exception? Failure = null); diff --git a/src/Repl.Tests/Given_ExitCodes.cs b/src/Repl.Tests/Given_ExitCodes.cs index 93ba6de7..2767a0ec 100644 --- a/src/Repl.Tests/Given_ExitCodes.cs +++ b/src/Repl.Tests/Given_ExitCodes.cs @@ -969,6 +969,26 @@ private static int Run(ReplApp sut, string[] args, out string output) return exitCode; } + [TestMethod] + [Description("An activation failure must still tell the operator why. Marking what escapes application code during binding exists so a remote host can withhold it, and the marker is a wrapper — rendering the wrapper's own message here would leave a console operator with the parameter's name and nothing about the cause, which is the diagnostic they came for.")] + public async Task When_ADependencyFactoryThrows_Then_TheLocalDiagnosticNamesTheCause() + { + var sut = ReplApp.Create(services => services.AddSingleton( + implementationFactory: static _ => throw new InvalidOperationException("factory-cause-detail"))); + sut.Map("work", (IFailingDependency dependency) => dependency.ToString() ?? "ok"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + using var session = OpenSession(out var writer); + await sut.RunAsync(["work"], cts.Token).ConfigureAwait(false); + + writer.ToString().Should().Contain( + "factory-cause-detail", + because: "the operator is the reader here, and the cause is the whole content of the diagnostic"); + } + + /// A dependency whose registration always fails; only its activation path matters. + public interface IFailingDependency; + private static IDisposable OpenSession(out StringWriter writer) { writer = new StringWriter(); From 7188a58c7f48042d69dc9b781efaa3a30d5d8ac5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 13:55:27 -0400 Subject: [PATCH 02/15] feat(mcp)!: migrate to ModelContextProtocol 2.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requires the SDK 2.x line. IMcpFeedback.SendMessageAsync takes a level and the message contract is Repl's own; 1.4.x hosts do not compile against this. The regression suite moves with the migration rather than after it: the 2.x line deprecates the Logging and Sampling surfaces, so the existing tests do not compile until they are updated alongside the code. **Per-request capability binding.** Revision 2026-07-28 conveys version, identity and capabilities as per-request metadata and establishes no session, and the SDK builds a destination-bound server per message. Capability services resolve the flowing request through an AsyncLocal accessor instead of a shared field, so two clients on one options instance no longer cross-wire, and McpSessionContext owns what is genuinely per connection — the session object, the snapshot cache, the roots cache, the compatibility bootstrap — under a reference-counted lifecycle. **The advertised tool set is invariant on 2026-07-28.** That revision requires it not to vary per connection nor change as a side effect of another request. Discovery therefore answers every session-scoped input a presence predicate can receive with a constant that reaches no live service: the four capability services, session state, and session metadata. Execution decides presence from those same answers, so what a catalog advertised is what its clients can call. Earlier revisions keep the per-session view, which is what their dynamic-tools story depends on. **Native roots resolve once per scope.** Connection scope under `mcp serve`, request scope on a reused BuildMcpServerOptions() result, so one client's workspace never reaches another. Concurrent first callers share one roots/list; a failed attempt is retracted rather than latched; an answer retired by roots/list_changed while still in flight is refetched rather than served; and the eager prime stands down after an expensive failure instead of paying the full budget on every execution. **A failure tells the client only what was written for it.** A handler's own failure result and the framework's refusals travel unchanged — an agent corrects itself from them. What the framework rendered from an exception that escaped application code does not, since it can name a path, a parameter's CLR type or a connection string, and over MCP the reader is remote rather than the operator. McpException and McpInteractionException are raised deliberately and addressed to the client, so they keep their message. Feedback a client cannot receive as a notification rides back in the result rather than being dropped; a resource read that succeeds keeps only its typed body, and one that fails carries the feedback in its error. The service overlay also answers IServiceProviderIsService, not only IServiceProvider. The SDK asks that question when it decides whether a handler parameter is a dependency or a client-supplied argument, so without it a prompt registered through options.Prompt(...) and declaring IMcpFeedback was classified as taking an argument named 'feedback' and could not be invoked at all. Those prompts are the second prebuilt primitive that bypasses the adapter, so they now carry the same execution prologue as a raw UI resource: roots resolved before the handler reads them, and a buffer open so feedback rides back in the result when the client cannot receive a notification. A transient projection failure still preserves availability, but modern connections now fall back to one shared last-known-good catalog rather than each to its own. Falling back per session is how the advertised set would come to vary by connection — a connection that had not yet seen a routing change would keep its older catalog while another served the newer one — which is the variance the revision forbids, and buying availability with it trades the guarantee for what the guarantee exists to prevent. --- src/Directory.Packages.props | 2 +- src/Repl.Mcp/IMcpClientRoots.cs | 12 +- src/Repl.Mcp/IMcpFeedback.cs | 29 +- src/Repl.Mcp/McpAppResource.cs | 92 +- src/Repl.Mcp/McpCacheHints.cs | 43 + src/Repl.Mcp/McpClientRootsService.cs | 376 ++++- src/Repl.Mcp/McpDiscoveryCapabilities.cs | 201 +++ src/Repl.Mcp/McpElicitationService.cs | 14 +- src/Repl.Mcp/McpExplicitPrompt.cs | 90 ++ src/Repl.Mcp/McpFeedbackService.cs | 216 ++- src/Repl.Mcp/McpInteractionChannel.cs | 43 +- src/Repl.Mcp/McpMessageLevel.cs | 37 + src/Repl.Mcp/McpProtocolRevisions.cs | 30 + src/Repl.Mcp/McpRequestServerAccessor.cs | 44 + src/Repl.Mcp/McpRootsScope.cs | 26 + src/Repl.Mcp/McpSamplingService.cs | 17 +- src/Repl.Mcp/McpServerHandler.cs | 527 ++++--- src/Repl.Mcp/McpServiceProviderOverlay.cs | 21 +- src/Repl.Mcp/McpSessionContext.cs | 104 ++ src/Repl.Mcp/McpToolAdapter.cs | 223 ++- src/Repl.Mcp/README.md | 38 +- src/Repl.Mcp/ReplMcpServerPrompt.cs | 39 +- src/Repl.Mcp/ReplMcpServerResource.cs | 7 +- src/Repl.Mcp/ReplMcpServerTool.cs | 16 +- src/Repl.Mcp/ReplMcpServerUiResource.cs | 19 +- .../Given_McpAgentCapabilities.cs | 5 + src/Repl.McpTests/Given_McpApps.cs | 330 ++++ .../Given_McpConcurrentSessions.cs | 1334 +++++++++++++++++ src/Repl.McpTests/Given_McpDebounce.cs | 31 + src/Repl.McpTests/Given_McpIntegration.cs | 82 + .../Given_McpResourceParameters.cs | 3 +- .../Given_McpRootsAndDynamicTools.cs | 126 +- .../Given_McpSharedServerOptions.cs | 571 +++++++ src/Repl.McpTests/Given_McpSubscriptions.cs | 173 +++ src/Repl.McpTests/Given_McpToolAdapter.cs | 3 +- src/Repl.McpTests/Given_McpUserFeedback.cs | 683 ++++++++- src/Repl.McpTests/McpPipeSession.cs | 118 ++ src/Repl.McpTests/McpTestFixture.cs | 127 +- 38 files changed, 5401 insertions(+), 451 deletions(-) create mode 100644 src/Repl.Mcp/McpCacheHints.cs create mode 100644 src/Repl.Mcp/McpDiscoveryCapabilities.cs create mode 100644 src/Repl.Mcp/McpExplicitPrompt.cs create mode 100644 src/Repl.Mcp/McpMessageLevel.cs create mode 100644 src/Repl.Mcp/McpProtocolRevisions.cs create mode 100644 src/Repl.Mcp/McpRequestServerAccessor.cs create mode 100644 src/Repl.Mcp/McpRootsScope.cs create mode 100644 src/Repl.Mcp/McpSessionContext.cs create mode 100644 src/Repl.McpTests/Given_McpSharedServerOptions.cs create mode 100644 src/Repl.McpTests/Given_McpSubscriptions.cs create mode 100644 src/Repl.McpTests/McpPipeSession.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8a0a7bcc..b8d7f6db 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,7 +14,7 @@ - + diff --git a/src/Repl.Mcp/IMcpClientRoots.cs b/src/Repl.Mcp/IMcpClientRoots.cs index dcfc1a3a..8b7a9cf0 100644 --- a/src/Repl.Mcp/IMcpClientRoots.cs +++ b/src/Repl.Mcp/IMcpClientRoots.cs @@ -17,8 +17,18 @@ public interface IMcpClientRoots /// /// Gets the current effective roots for the session. - /// Native roots are preferred when supported; otherwise soft roots are returned. + /// Native roots are preferred once resolved; otherwise soft roots are returned. /// + /// + /// Under mcp serve, where this state belongs to the connection, a client that supports native + /// roots but has not been asked yet or could not be reached leaves nothing resolved, and soft roots + /// stand in for that — so an empty result means the roots in force are empty, not that resolving them + /// failed. On a reused BuildMcpServerOptions() result the state belongs to the request instead, + /// and a roots-capable client reads empty until has been called within that + /// request; soft roots answer only when the client supports no native roots at all. Either way, call + /// when the difference matters: it resolves on demand and surfaces a failure + /// instead of absorbing it. + /// IReadOnlyList Current { get; } /// diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 3fff6a16..0484bcf4 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Protocol; using Repl.Interaction; namespace Repl.Mcp; @@ -17,8 +16,24 @@ public interface IMcpFeedback bool IsProgressSupported { get; } /// - /// Gets a value indicating whether the connected MCP client can receive logging/message notifications. + /// Gets a value indicating whether the current request has a severity threshold at all, so a + /// message at or above it would reach the connected MCP client as a notification. /// + /// + /// On the 2026-07-28 revision this is unless the request declared + /// a log level in its metadata, because the specification forbids emitting message notifications + /// for a request that did not ask for them. A message sent while this is + /// during a tool call is not lost: it is carried back in the tool result instead. A resource + /// read has no such place to put it — a resource body must match its advertised MIME type — so a + /// message reported from a command serving a resource is dropped when the read succeeds. A read that + /// fails has no body at all, and carries the message in the surfaced error instead. + /// + /// A threshold existing is not a promise that every message arrives. An initialize-era host that + /// asked for Error leaves this while anything below that level is + /// dropped — and dropped messages are not carried back in the tool result, because the + /// client asked not to receive them. + /// + /// bool IsLoggingSupported { get; } /// @@ -29,10 +44,16 @@ ValueTask ReportProgressAsync( CancellationToken cancellationToken = default); /// - /// Sends a structured MCP message notification to the connected client. + /// Sends a message to the connected client, as a notification when the request asked for one and + /// otherwise as part of the result. /// + /// + /// A resource read that succeeds is the one path that keeps only its body, whose MIME type it has + /// already advertised, and drops what was buffered; a read that fails carries it in the surfaced + /// error. Everywhere else an undeliverable message is appended to the result rather than lost. + /// ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default); } diff --git a/src/Repl.Mcp/McpAppResource.cs b/src/Repl.Mcp/McpAppResource.cs index f9370aa5..08a38af0 100644 --- a/src/Repl.Mcp/McpAppResource.cs +++ b/src/Repl.Mcp/McpAppResource.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -7,12 +8,17 @@ internal sealed class McpAppResource : McpServerResource { private readonly McpAppResourceRegistration _registration; private readonly IServiceProvider _services; + private readonly McpRequestServerAccessor _servers; private readonly ResourceTemplate _protocolResourceTemplate; - public McpAppResource(McpAppResourceRegistration registration, IServiceProvider services) + public McpAppResource( + McpAppResourceRegistration registration, + IServiceProvider services, + McpRequestServerAccessor servers) { _registration = registration; _services = services; + _servers = servers; _protocolResourceTemplate = new ResourceTemplate { Name = registration.Options.Name ?? registration.Uri, @@ -30,20 +36,82 @@ public McpAppResource(McpAppResourceRegistration registration, IServiceProvider public override bool IsMatch(string uri) => string.Equals(uri, _registration.Uri, StringComparison.OrdinalIgnoreCase); + /// + /// Surfaces a failed read together with whatever feedback the handler buffered, or leaves the + /// exception alone when it buffered none. + /// + /// + /// A failed read has no body to carry what the handler reported, so the surfaced error is the only + /// place left for it — the same treatment the command-backed resource paths give it. Only the + /// app-authored feedback travels: the handler's own message stays behind, because it can name a path + /// from an or a parameter and its full CLR type from a binding failure. + /// Withholding it is not a courtesy but a matter of not undoing the SDK, which flattens any + /// non- to "An error occurred." and passes an 's + /// message through verbatim — so wrapping is the act that would disclose it, and returning without + /// throwing is what leaves the empty case sanitized. The same rule as + /// McpServerHandler.ThrowSanitizedIfAClientAlreadyHasASchema. + /// + /// An is the exception to that: raising one is a deliberate act and its + /// message was written for this client, which is why the SDK lets it through. Replacing it because + /// the handler also reported something would make the feedback cost the explanation — and the same + /// failure without feedback would explain itself, which no caller could account for. + /// + /// + private static void ThrowWithBufferedFeedback( + Exception exception, + McpFeedbackService.UndeliveredMessageScope undelivered) + { + var drained = undelivered.Messages.Drain(); + if (drained.Count == 0) + { + return; + } + + var surfaced = exception is McpException ? exception.Message : "MCP App resource read failed."; + throw new McpException(McpToolAdapter.AppendMessages(surfaced, drained), exception); + } + public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { - var html = await McpAppResourceInvoker - .InvokeAsync( - _registration.Handler, - _services, - new McpAppResourceContext(request.Params.Uri), - request, - cancellationToken) - .ConfigureAwait(false); - - return new ReadResourceResult + // Like every other prebuilt primitive: the reusable-options path dispatches straight into this + // resource, so without binding here a handler injecting IMcpClientRoots, IMcpSampling, + // IMcpElicitation or IMcpFeedback sees no flowing request and reports the client as incapable. + _servers.BindRequest(request); + + // This is the one primitive that does not run through McpToolAdapter, so the execution prologue + // every command-backed path gets has to be repeated here: roots primed so a handler reading + // Current sees them, and a buffer open so feedback the client cannot receive as a notification + // is not simply lost. + await McpClientRootsService.PrimeFromServicesAsync(_services, cancellationToken).ConfigureAwait(false); + var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; + using var undelivered = feedbackService?.PushUndeliveredMessages(); + + string html; + try + { + html = await McpAppResourceInvoker + .InvokeAsync( + _registration.Handler, + _services, + new McpAppResourceContext(request.Params.Uri), + request, + cancellationToken) + .ConfigureAwait(false); + } + // Cancellation is told apart by who asked for it, not by the exception's type: a handler that runs + // its own budget and gives up reports a failure like any other and its feedback still matters. Only + // the caller abandoning the request takes the bare path, which is the same rule + // McpClientRootsService.PrimeFromServicesAsync applies. + catch (Exception exception) when (undelivered is not null + && (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)) + { + ThrowWithBufferedFeedback(exception, undelivered); + throw; + } + + return McpCacheHints.MarkPrivateToThisClient(request, new ReadResourceResult { Contents = [ @@ -55,6 +123,6 @@ public override async ValueTask ReadAsync( Meta = McpAppMetadata.BuildResourceMeta(_registration.Options), }, ], - }; + }); } } diff --git a/src/Repl.Mcp/McpCacheHints.cs b/src/Repl.Mcp/McpCacheHints.cs new file mode 100644 index 00000000..5f021b71 --- /dev/null +++ b/src/Repl.Mcp/McpCacheHints.cs @@ -0,0 +1,43 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Cache hints Repl attaches to results whose content depends on which client asked. +/// +internal static class McpCacheHints +{ + /// + /// Marks as belonging to the requesting client alone, and as immediately + /// stale — on the revisions that have somewhere to put that. + /// + /// + /// Set rather than left to a default, because the default is the wrong one: SEP-2549 reads an + /// absent cacheScope as Public, and everything this package returns can vary by + /// client — a command graph gated on that client's roots, a resource body produced by running a + /// command for it. A shared gateway is entitled to serve a Public result to the next caller. + /// Applied at the primitives as well as the handler, because a server built from + /// BuildMcpServerOptions() dispatches straight into the primitives and never reaches a + /// handler. + /// + /// Both fields arrived with 2026-07-28 and are absent from the initialize-era result schema, + /// so an older client is left untagged. It cannot be reached through a cache that understands these + /// hints anyway, and a strict implementation may reject a response carrying a field its schema does + /// not define — on the compatibility path this package exists to keep working. + /// + /// + public static TResult MarkPrivateToThisClient(MessageContext request, TResult result) + where TResult : ICacheableResult + { + var protocolVersion = (request.JsonRpcMessage as JsonRpcRequest)?.Context?.ProtocolVersion; + if (!McpProtocolRevisions.CarriesSessionlessFields(protocolVersion)) + { + return result; + } + + result.CacheScope = CacheScope.Private; + result.TimeToLive = TimeSpan.Zero; + return result; + } +} diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index fa53d4e3..058b34f3 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -1,24 +1,68 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; +// Caches native roots for exactly as long as its McpRootsScope allows, and no longer. Outbound +// transport goes through the request-bound accessor under either scope: the destination is per +// request, which is finer than a session. internal sealed class McpClientRootsService : IMcpClientRoots { private readonly ICoreReplApp _app; + private readonly McpRequestServerAccessor _servers; + private readonly McpRootsScope _scope; private readonly Lock _syncRoot = new(); - private McpServer? _server; + // Bounds the one outbound call this type makes. Request scope pays it per request rather than once + // per connection, so a client that never answers roots/list would otherwise hold every tool call + // that resolves roots open with nothing to stop it. + private static readonly TimeSpan RootsRequestBudget = TimeSpan.FromSeconds(10); + // How long the eager prime stands down after an expensive failure. A client that declares the roots + // capability and then never answers would otherwise cost the full budget above on every single + // execution, for the life of the connection: nothing caches a failure, and the shared attempt is + // retracted at the next acquisition precisely so the next caller retries. That retry is right for a + // handler that asks for roots and wrong for the prime, which asks on nobody's behalf. + private static readonly TimeSpan PrimeRetryCooldown = TimeSpan.FromSeconds(30); + + // How many times one call will ask before giving up. A roots/list_changed processed while a fetch is + // unanswered retires that fetch's answer, and the caller must not be handed it: the client has said + // those roots no longer apply, and nothing in the value distinguishes it from a current one. Asking + // again is the only way to honour the call, and the count bounds it because the client decides how + // often it invalidates. + private const int MaxRootsFetchAttempts = 3; + + // Only a failure that actually spent the budget is worth standing down for. A client that answers + // roots/list promptly with something unusable costs nothing to ask again, and backing off there + // would just hold Current empty for half a minute after a fault that may already have cleared. + private static readonly TimeSpan PrimeStandDownThreshold = TimeSpan.FromMilliseconds( + RootsRequestBudget.TotalMilliseconds / 2); + + // Request scope only. Keyed by the flowing request, so entries die with it and nothing here ever + // needs invalidating. + private readonly ConditionalWeakTable _requestRoots = new(); + // Connection scope only. private McpClientRoot[] _hardRoots = []; private McpClientRoot[] _softRoots = []; private bool _hardRootsLoaded; + private Task>? _hardRootsPending; private long _hardRootsVersion; + // When the eager prime last failed, so it can stop paying the full budget on every execution. + private long? _primeFailedAt; - public McpClientRootsService(ICoreReplApp app) + public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers, McpRootsScope scope) { _app = app; + _servers = servers; + _scope = scope; } - public bool IsSupported => _server?.ClientCapabilities?.Roots is not null; + public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null; public bool HasSoftRoots { @@ -35,39 +79,162 @@ public IReadOnlyList Current { get { + if (_scope is McpRootsScope.Request) + { + // Only what this request already resolved. Answering with another connection's cached + // roots is the same disclosure as GetAsync's, reached without any round-trip at all. + // One read of the flowing request, like GetAsync below. + if (_servers.Current is not { } request) + { + return GetSoftRoots(); + } + + return _requestRoots.TryGetValue(request, out var entry) && entry.Resolved is { } resolved + ? resolved + : request.Server.ClientCapabilities?.Roots is not null ? [] : GetSoftRoots(); + } + lock (_syncRoot) { - return IsSupported ? _hardRoots : _softRoots; + // Native roots stand in for soft ones only once they have actually been resolved. Until + // then — never primed, or primed and failed — _hardRoots is empty, and answering with it + // would report "this client declared no roots" for what is really "nobody could ask it", + // which is the reading a handler is most likely to act on and the one it cannot check. + // A client that genuinely answers with zero roots sets _hardRootsLoaded, so that case is + // still told apart from this one. + return IsSupported && _hardRootsLoaded ? _hardRoots : _softRoots; } } } - public void AttachServer(McpServer server) + /// + /// Resolves this connection's native roots so that answers with them without + /// the caller having to ask first. A no-op outside connection scope. + /// + /// + /// Called at the command execution boundary. Connection scope is where promises + /// the session's roots, so a handler reading it must not have to prime the cache itself; the answer is + /// then cached for the life of the connection, which is one roots/list — the same cost as the + /// discovery-time pre-resolution this replaces. Request scope is deliberately excluded: there + /// is documented as only what this request already resolved, and an eager fetch + /// would add a round-trip to every request rather than to every connection. + /// + internal async ValueTask PrimeCurrentAsync(CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(server); - _server = server; + if (_scope is not McpRootsScope.Connection || !IsSupported) + { + return; + } + + lock (_syncRoot) + { + // Standing down is only ever right while there is still nothing cached; once a fetch has + // succeeded GetAsync answers from the cache and costs nothing to call. + if (!_hardRootsLoaded + && _primeFailedAt is { } failedAt + && Stopwatch.GetElapsedTime(failedAt) < PrimeRetryCooldown) + { + return; + } + } + + var startedAt = Stopwatch.GetTimestamp(); + long versionAtStart; + lock (_syncRoot) + { + versionAtStart = _hardRootsVersion; + } + + try + { + await GetAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + if (Stopwatch.GetElapsedTime(startedAt) >= PrimeStandDownThreshold) + { + lock (_syncRoot) + { + // Not if the client said its roots changed while this attempt was running. The + // notification clears the stand-down on purpose — whatever made the attempt fail may be + // exactly what it is reporting — and an attempt that began before it must not put the + // stand-down back afterwards, which is what a slow failure would otherwise do. Then no + // execution would prime for half a minute despite the client having asked for it. + if (_hardRootsVersion == versionAtStart) + { + _primeFailedAt = Stopwatch.GetTimestamp(); + } + } + } + + throw; + } + + lock (_syncRoot) + { + _primeFailedAt = null; + } } public async ValueTask> GetAsync(CancellationToken cancellationToken = default) { - var server = _server; - if (server?.ClientCapabilities?.Roots is null) + // Single read: the effective server must not change between the support check and + // the roots request (a concurrent request re-binding the accessor must not be observed). + if (_servers.Current is not { } request || request.Server.ClientCapabilities?.Roots is null) { return Current; } - long versionAtStart; - lock (_syncRoot) + var server = request.Server; + if (_scope is McpRootsScope.Request) { - if (_hardRootsLoaded) + // The table hands every caller in this request the same entry; the entry, not the table's + // factory, starts the fetch. ConditionalWeakTable.GetValue documents that it may run its + // factory more than once for one key and outside its own lock, so a factory that started + // work would issue a second roots/list and orphan one of the two results. + var entry = _requestRoots.GetValue(request, static _ => new RequestRoots()); + return await entry.ResolveAsync(server, cancellationToken).ConfigureAwait(false); + } + + for (var attempt = 1; ; attempt++) + { + Task> pending; + lock (_syncRoot) { - return _hardRoots; + if (_hardRootsLoaded) + { + return _hardRoots; + } + + var version = _hardRootsVersion; + pending = JoinOrStartAsync(ref _hardRootsPending, () => FetchHardRootsOnceAsync(server, version)); } - versionAtStart = _hardRootsVersion; - } + // Waited on this caller's token while the fetch runs on its own budget, the same shape request + // scope uses: concurrent first calls share one roots/list, and a caller giving up releases only + // itself. The answer is read back from the cache rather than from the task, because whether it + // was cached is exactly what says it is still current. +#pragma warning disable VSTHRD003 // Started by this instance, a few lines above. + await pending.WaitAsync(cancellationToken).ConfigureAwait(false); +#pragma warning restore VSTHRD003 + + lock (_syncRoot) + { + if (_hardRootsLoaded) + { + return _hardRoots; + } + } - return await GetAndMaybeCacheRootsAsync(server, versionAtStart, cancellationToken).ConfigureAwait(false); + // Nothing cached means the version moved while that fetch was unanswered, so the answer it + // carries was retracted before it arrived. Refusing to cache it is not enough: handing it back + // gives this caller roots the client has withdrawn, and nothing in the value says so. + if (attempt >= MaxRootsFetchAttempts) + { + throw new McpException("Client roots changed repeatedly while they were being resolved."); + } + } } public void SetSoftRoots(IEnumerable roots) @@ -109,6 +276,9 @@ public void ClearSoftRoots() } } + // Reached only under connection scope: the notification handler is registered from AttachSession, + // which never runs on the path that builds a request-scoped service. Clearing the connection fields + // is harmless either way, since request scope never writes them. public void HandleRootsListChanged() { lock (_syncRoot) @@ -116,20 +286,148 @@ public void HandleRootsListChanged() _hardRoots = []; _hardRootsLoaded = false; _hardRootsVersion++; + + // Retired with the array it produced, and under the same lock. The task is kept to coalesce + // concurrent first calls; leaving a COMPLETED one here would make the next execution replay + // the pre-notification answer and send no roots/list at all, so the cache would be cleared + // with nothing left to refill it. A fetch still in flight is abandoned rather than awaited: + // it started before the change and the version check already stops it caching. + _hardRootsPending = null; + + // The client has just said something changed, which is the one signal worth interrupting the + // prime's stand-down for: whatever made the last attempt fail may be what it is reporting. + _primeFailedAt = null; } _app.InvalidateRouting(); } - private async ValueTask> GetAndMaybeCacheRootsAsync( + private McpClientRoot[] GetSoftRoots() + { + lock (_syncRoot) + { + return _softRoots; + } + } + + private static async Task FetchRootsAsync( McpServer server, - long versionAtStart, CancellationToken cancellationToken) { var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken) .ConfigureAwait(false); - var mappedRoots = result.Roots?.Select(MapRoot).ToArray() ?? []; + return result.Roots?.Select(MapRoot).ToArray() ?? []; + } + /// + /// Joins the attempt already outstanding in , or starts one when there is + /// none left to join. + /// + /// + /// Both scopes coalesce their concurrent first callers onto a single roots/list, and the rule + /// for doing it is subtle enough that keeping two copies of it has cost this branch three rounds of + /// fixing one and missing the other. A failed attempt is retracted here, at acquisition, rather than + /// where a waiter observes the failure: the last waiter can abandon its wait while the attempt is + /// still running, and then nothing is left to retract it when it faults afterwards. The fault is + /// spoken for on the way out for that same reason. Callers hold their own lock across this, which is + /// what makes the decision atomic against whatever else that lock guards, and each then waits on its + /// own token so that one caller giving up releases only itself. + /// + private static Task JoinOrStartAsync(ref Task? pending, Func> start) + { + if (pending is { IsCompleted: true } settled && !settled.IsCompletedSuccessfully) + { + pending = null; + } + + if (pending is null) + { + pending = start(); + ObserveFault(pending); + } + + return pending; + } + + /// + /// Speaks for 's fault, so that nobody has to. + /// + /// + /// Every waiter leaves on its own token, so a shared fetch can fault with nobody left to read it, + /// and it is then dropped unread — replaced at the next acquisition, retired by + /// , or simply collected when the connection ends. Without this + /// the exception reaches from a finalizer, + /// long after the request that caused it, and a host configured to throw on that crashes. The idiom + /// matches ReplProcessSignalHarness's. + /// + private static void ObserveFault(Task fetch) => + _ = fetch.ContinueWith( + static observed => _ = observed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// + /// The one outstanding connection-scoped fetch, shared by every caller that arrives before it + /// completes. + /// + /// + /// Without this, two first invocations on one connection each send their own roots/list and + /// one result is discarded, which the "one round-trip per connection" cost claim does not allow. It + /// carries its own budget for the same reason request scope does: the result belongs to every + /// caller, so no single caller's token may bound it. + /// + private async Task> FetchHardRootsOnceAsync(McpServer server, long versionAtStart) + { + using var budget = new CancellationTokenSource(RootsRequestBudget); + return await GetAndMaybeCacheRootsAsync(server, versionAtStart, budget.Token).ConfigureAwait(false); + } + + /// + /// Primes the connection's native roots from , swallowing a failure. + /// + /// + /// Called from every execution entry point. A handler that never reads roots must not fail because + /// the client could not answer, and one that does read them surfaces the error from its own + /// . Cancellation is the caller's and propagates. + /// + internal static async ValueTask PrimeFromServicesAsync( + IServiceProvider services, + CancellationToken cancellationToken) + { + if (services.GetService(typeof(IMcpClientRoots)) is not McpClientRootsService roots) + { + return; + } + + try + { + await roots.PrimeCurrentAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + // Swallowed: see the remarks above. + } + } + + private async ValueTask> GetAndMaybeCacheRootsAsync( + McpServer server, + long versionAtStart, + CancellationToken cancellationToken) + { + var mappedRoots = await FetchRootsAsync(server, cancellationToken).ConfigureAwait(false); + + // Caching is refused for an answer whose version was retired while it was still outstanding: it + // would pin roots the client has already retracted, with nothing left to refill them. The refusal + // is also the signal GetAsync reads — an uncached answer is a retracted one, and that caller asks + // again rather than receive it. The returned value is therefore only meaningful when it was + // cached. Ordering a roots/list_changed against an unanswered fetch is awkward but not impossible: + // the guard holds the answer until the server echoes tools/list_changed, which it emits from the + // same handler that moves the version. lock (_syncRoot) { if (_hardRootsVersion == versionAtStart) @@ -143,6 +441,46 @@ private async ValueTask> GetAndMaybeCacheRootsAsync } } + /// + /// One request's native roots: fetched at most once, and forgotten with the request. + /// + private sealed class RequestRoots + { + private readonly Lock _gate = new(); + private Task? _pending; + private McpClientRoot[]? _resolved; + + /// What this request has settled on, or while it has not. + public McpClientRoot[]? Resolved => Volatile.Read(ref _resolved); + + public async Task> ResolveAsync( + McpServer server, + CancellationToken cancellationToken) + { + Task pending; + lock (_gate) + { + pending = JoinOrStartAsync(ref _pending, () => FetchOnceAsync(server)); + } + + // Waited on this caller's token while the fetch itself runs on its own budget: one caller + // giving up must release that caller, and must not cancel the result the others share. +#pragma warning disable VSTHRD003 // Started by this instance, for this request, one line above. + return await pending.WaitAsync(cancellationToken).ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + + private async Task FetchOnceAsync(McpServer server) + { + // Its own budget rather than a caller's token: the result is shared by every caller in this + // request, so cancelling one must not cancel the others, and nothing else bounds the wait. + using var budget = new CancellationTokenSource(RootsRequestBudget); + var roots = await FetchRootsAsync(server, budget.Token).ConfigureAwait(false); + Volatile.Write(ref _resolved, roots); + return roots; + } + } + private static McpClientRoot MapRoot(Root root) { var uri = Uri.TryCreate(root.Uri, UriKind.Absolute, out var parsed) diff --git a/src/Repl.Mcp/McpDiscoveryCapabilities.cs b/src/Repl.Mcp/McpDiscoveryCapabilities.cs new file mode 100644 index 00000000..fa7f14ed --- /dev/null +++ b/src/Repl.Mcp/McpDiscoveryCapabilities.cs @@ -0,0 +1,201 @@ +using Repl.Interaction; +using Repl.Terminal; + +namespace Repl.Mcp; + +/// +/// The session-scoped services as discovery sees them on a modern revision: constants, holding +/// no reference to the live services, so the advertised command graph depends only on +/// application-global state. +/// +/// +/// Revision 2026-07-28 conveys version, identity and capabilities as per-request metadata and +/// has no session, and its tools chapter requires that the advertised set +/// "MUST NOT vary per-connection or as a side effect of other requests on the connection" — +/// the one stated exception being the authorization presented on the request. +/// +/// These wrappers therefore delegate nothing. Holding no inner service makes the +/// invariant structural rather than a property each member has to keep: whichever member a predicate +/// reaches for — IsSupported, HasSoftRoots, Current, GetAsync — there is +/// nothing per-connection behind it. +/// +/// +/// The capability questions answer "available" so that a gated command is advertised rather than +/// silently dropped, and the data questions answer "nothing", which is the only constant a list can +/// honestly take. The action members are inert: a presence predicate must not reach the client at all, +/// since doing so would be both a per-connection dependency and a side effect of discovery. +/// +/// +/// Execution binds handlers from the real services, so a command that needs roots and is called by a +/// client without them runs and can report exactly that, and soft roots set by a command remain fully +/// visible through there. Execution does take these answers for +/// deciding presence, so that what was advertised is what can be called; deciding it twice +/// from two views is what would make a tool visible and unreachable. Only the automatic revealing of +/// commands goes away. +/// +/// +/// The legacy revisions establish a session with an initialize handshake and state no such +/// invariant, so they keep the per-session view: these wrappers are applied on modern requests only. +/// +/// +internal static class McpDiscoveryCapabilities +{ + public static IMcpClientRoots Roots { get; } = new DiscoveryClientRoots(); + + public static IMcpSampling Sampling { get; } = new DiscoverySampling(); + + public static IMcpElicitation Elicitation { get; } = new DiscoveryElicitation(); + + public static IMcpFeedback Feedback { get; } = new DiscoveryFeedback(); + + /// + /// Session state as discovery sees it: empty, and unchanged by anything written to it. + /// + /// + /// The capability services cover the per-connection half of the rule. This covers the other half, + /// which needs no second connection to be observable: session state is a mutable singleton that a + /// command can write, and a predicate reading it would let a tools/call decide what the next + /// tools/list advertises — precisely the side effect the revision forbids. Reads answer + /// "absent" because that is the only constant a store of arbitrary keys can honestly take; writes + /// are inert, since a predicate must not mutate what it is measuring. + /// + public static IReplSessionState SessionState { get; } = new DiscoverySessionState(); + + /// + /// Session metadata as discovery sees it: nothing known. + /// + /// + /// The live implementation is a façade over the ambient session, so its answers move with whichever + /// connection happens to be resolving. A predicate gated on a terminal size or a transport name + /// would therefore vary per connection, which is the first half of the rule. + /// + public static IReplSessionInfo SessionInfo { get; } = new DiscoverySessionInfo(); + + private sealed class DiscoveryClientRoots : IMcpClientRoots + { + public bool IsSupported => true; + + public bool HasSoftRoots => false; + + public IReadOnlyList Current => []; + + public ValueTask> GetAsync(CancellationToken cancellationToken = default) => + ValueTask.FromResult>([]); + + public void SetSoftRoots(IEnumerable roots) + { + // Inert: discovery must not mutate the state it is projecting. + } + + public void ClearSoftRoots() + { + // Inert, for the same reason as SetSoftRoots. + } + } + + /// + /// The frozen answers as a set, for the two places that must agree on them. + /// + /// + /// Discovery decides what is advertised; execution decides whether an advertised command exists. + /// Those are the same question, and answering it twice from two different views is what makes a + /// tool visible and uncallable. A fresh dictionary per call because the overlay owns what it is + /// given. + /// + public static Dictionary CreateSessionScopedOverrides() => new() + { + [typeof(IMcpClientRoots)] = Roots, + [typeof(IMcpSampling)] = Sampling, + [typeof(IMcpElicitation)] = Elicitation, + [typeof(IMcpFeedback)] = Feedback, + [typeof(IReplSessionState)] = SessionState, + [typeof(IReplSessionInfo)] = SessionInfo, + }; + + private sealed class DiscoverySessionState : IReplSessionState + { + public bool TryGet(string key, out T? value) + { + value = default; + return false; + } + + public T? Get(string key) => default; + + public void Set(string key, T value) + { + // Inert: a predicate must not mutate what it is measuring. + } + + public bool Remove(string key) => false; + + public void Clear() + { + // Inert, for the same reason as Set. + } + } + + private sealed class DiscoverySessionInfo : IReplSessionInfo + { + public (int Width, int Height)? WindowSize => null; + + public bool AnsiSupported => false; + + public string? TransportName => null; + + public string? RemotePeer => null; + + public TerminalCapabilities TerminalCapabilities => TerminalCapabilities.None; + + public string? TerminalIdentity => null; + + public string? ShellIntegrationStatus => null; + } + + private sealed class DiscoverySampling : IMcpSampling + { + public bool IsSupported => true; + + public ValueTask SampleAsync( + string prompt, + int maxTokens = 1024, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(null); + } + + private sealed class DiscoveryElicitation : IMcpElicitation + { + public bool IsSupported => true; + + public ValueTask ElicitTextAsync(string message, CancellationToken cancellationToken = default) => + ValueTask.FromResult(null); + + public ValueTask ElicitBooleanAsync(string message, CancellationToken cancellationToken = default) => + ValueTask.FromResult(null); + + public ValueTask ElicitChoiceAsync( + string message, + IReadOnlyList choices, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(null); + + public ValueTask ElicitNumberAsync(string message, CancellationToken cancellationToken = default) => + ValueTask.FromResult(null); + } + + private sealed class DiscoveryFeedback : IMcpFeedback + { + public bool IsProgressSupported => true; + + public bool IsLoggingSupported => true; + + public ValueTask ReportProgressAsync( + ReplProgressEvent progress, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask SendMessageAsync( + McpMessageLevel level, + object? data, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + } +} diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs index 12de5219..40a57af9 100644 --- a/src/Repl.Mcp/McpElicitationService.cs +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -15,13 +15,11 @@ namespace Repl.Mcp; /// a multi-field variant would build the with /// multiple properties instead of one. /// -internal sealed class McpElicitationService : IMcpElicitation +internal sealed class McpElicitationService(McpRequestServerAccessor servers) : IMcpElicitation { private const string FieldName = "value"; - private McpServer? _server; - - public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null; + public bool IsSupported => servers.Effective?.ClientCapabilities?.Elicitation is not null; public async ValueTask ElicitTextAsync( string message, @@ -99,19 +97,19 @@ internal sealed class McpElicitationService : IMcpElicitation : null; } - internal void AttachServer(McpServer server) => _server = server; - private async ValueTask ElicitSingleFieldAsync( string message, ElicitRequestParams.PrimitiveSchemaDefinition schema, CancellationToken cancellationToken) { - if (!IsSupported) + // Single read: the effective server must not change between the support check and + // the call (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { ClientCapabilities.Elicitation: not null } server) { return null; } - var result = await _server!.ElicitAsync( + var result = await server.ElicitAsync( new ElicitRequestParams { Message = message, diff --git a/src/Repl.Mcp/McpExplicitPrompt.cs b/src/Repl.Mcp/McpExplicitPrompt.cs new file mode 100644 index 00000000..d4c24024 --- /dev/null +++ b/src/Repl.Mcp/McpExplicitPrompt.cs @@ -0,0 +1,90 @@ +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Gives an explicitly registered prompt the execution prologue every command-backed path gets. +/// +/// +/// A prompt registered through options.Prompt(...) is a raw SDK primitive: the SDK invokes its +/// handler directly, so it never passes through . Without this it reaches +/// the handler with the connection's roots unresolved — a handler reading +/// would see an empty list and take it for a client that +/// declared no workspace — and with no buffer open, so on 2026-07-28 a request that declared no +/// log level loses its feedback silently, having no notification channel to receive it on. +/// +/// carries the same prologue for the same reason. Those two are the +/// prebuilt primitives that bypass the adapter; everything command-backed gets it from the adapter +/// itself. +/// +/// +internal sealed class McpExplicitPrompt( + McpServerPrompt inner, + IServiceProvider services, + McpRequestServerAccessor servers) : McpServerPrompt +{ + public override Prompt ProtocolPrompt => inner.ProtocolPrompt; + + public override IReadOnlyList Metadata => inner.Metadata; + + public override async ValueTask GetAsync( + RequestContext request, + CancellationToken cancellationToken = default) + { + // The reusable-options path dispatches straight into this prompt, so without binding here a + // handler injecting a capability service sees no flowing request and reports the client as + // incapable. + servers.BindRequest(request); + + // The SDK resolves this handler's parameters from the request's own provider, and nothing on + // this path sets one — so a prompt injecting a capability service could not be invoked at all. + // Every other execution path reaches the handler through the adapter, which supplies them. + request.Services = services; + + await McpClientRootsService.PrimeFromServicesAsync(services, cancellationToken).ConfigureAwait(false); + var feedbackService = services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; + using var undelivered = feedbackService?.PushUndeliveredMessages(); + + GetPromptResult result; + try + { + result = await inner.GetAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (undelivered is not null + && (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)) + { + // A failure is exactly when the notices leading up to it are worth having, and a failed + // prompt has no result left to carry them. + var failed = undelivered.Messages.Drain(); + if (failed.Count == 0) + { + throw; + } + + var surfaced = exception is McpException ? exception.Message : "Prompt execution failed."; + throw new McpException(McpToolAdapter.AppendMessages(surfaced, failed), exception); + } + + var drained = undelivered?.Messages.Drain() ?? []; + if (drained.Count == 0) + { + return result; + } + + // After the payload, as on the tool and command-backed prompt paths. + var messages = new List(result.Messages.Count + drained.Count); + messages.AddRange(result.Messages); + foreach (var message in drained) + { + messages.Add(new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = message }, + }); + } + + return new GetPromptResult { Messages = messages, Description = result.Description }; + } +} diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index 92a3a2ac..f4a02d59 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -5,24 +5,25 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// -/// Internal implementation of backed by a live session. +/// Internal implementation of backed by the flowing MCP request. /// -internal sealed class McpFeedbackService : IMcpFeedback +internal sealed class McpFeedbackService(McpRequestServerAccessor servers) : IMcpFeedback { private const string LoggerName = "repl.interaction"; private readonly AsyncLocal _progressToken = new(); - // This service is created per McpServerHandler/session and overlaid into the - // per-connection service provider, so the attached server reference is not shared - // across concurrent MCP connections. - private McpServer? _server; + private readonly AsyncLocal _undelivered = new(); - public bool IsProgressSupported => _server is not null && _progressToken.Value is not null; + public bool IsProgressSupported => servers.Effective is not null && _progressToken.Value is not null; - public bool IsLoggingSupported => _server is not null; + public bool IsLoggingSupported => ResolveThreshold() is not null; public async ValueTask ReportProgressAsync( ReplProgressEvent progress, @@ -30,13 +31,16 @@ public async ValueTask ReportProgressAsync( { ArgumentNullException.ThrowIfNull(progress); - if (!IsProgressSupported || progress.State == ReplProgressState.Clear || _progressToken.Value is not { } progressToken) + // Read once; see McpRequestServerAccessor for why a second read is not the same value. + if (servers.Effective is not { } server + || progress.State == ReplProgressState.Clear + || _progressToken.Value is not { } progressToken) { return; } var percent = progress.ResolvePercent(); - await _server!.NotifyProgressAsync( + await server.NotifyProgressAsync( progressToken, new ProgressNotificationValue { @@ -48,31 +52,131 @@ public async ValueTask ReportProgressAsync( } public async ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default) { - if (!IsLoggingSupported) + // Read once, both of them, for the reason on McpRequestServerAccessor: the decision and the + // send must agree about which request they belong to. + var server = servers.Effective; + var threshold = ResolveThreshold(); + + if (server is null || threshold is null || level < threshold) { + // Either the client cannot receive notifications for this request, or the message is + // below the level it asked for. Below-threshold messages are dropped as requested; + // undeliverable ones are kept so the tool result can carry them instead. + if (threshold is null) + { + _undelivered.Value?.Add(level, data); + } + return; } - await _server!.SendNotificationAsync( + await server.SendNotificationAsync( NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams { - Level = level, + Level = ToProtocol(level), Logger = LoggerName, Data = SerializeData(data), }, cancellationToken: cancellationToken).ConfigureAwait(false); } - internal void AttachServer(McpServer server) => _server = server; + /// + /// Resolves the severity threshold the current request asked for, or when + /// it asked for nothing and no notification may be sent. + /// + /// + /// The 2026-07-28 revision (SEP-2575) replaced logging/setLevel with a per-request + /// _meta/io.modelcontextprotocol/logLevel field and states that a server MUST NOT emit + /// message notifications for a request that omitted it. The SDK parses the field onto the message + /// context but consumes it nowhere, so the filtering is Repl's to do. + /// + /// Initialize-era revisions keep the session-wide logging/setLevel semantics, including the + /// historical behaviour of sending everything when the client never set a level — those hosts must + /// not lose feedback to a rule that does not apply to them. + /// + /// + /// Note that the SDK's own CLIENT cannot currently ask for notifications on 2026-07-28: it + /// rejects logging/setLevel on that revision, exposes no option for the level, and replaces + /// a caller's _meta with its own three keys (protocol version, client info, capabilities). + /// So in practice this returns for every SDK-client request on the modern + /// revision, which is exactly why undeliverable messages are carried back in the tool result. + /// + /// + private McpMessageLevel? ResolveThreshold() + { + if (servers.Current is not { } request) + { + return null; + } + + var context = (request.JsonRpcMessage as JsonRpcRequest)?.Context; + if (context?.LogLevel is { } requestedLevel) + { + return FromProtocol(requestedLevel); + } + + if (McpProtocolRevisions.CarriesSessionlessFields(context?.ProtocolVersion)) + { + return null; + } + + return request.Server.LoggingLevel is { } sessionLevel + ? FromProtocol(sessionLevel) + : McpMessageLevel.Debug; + } + + // Mapped member by member rather than cast. The two enums agree numerically today and the upgrade + // note tells consumers so, but a cast makes that agreement load-bearing: an SDK renumbering would + // relabel every severity on the wire with nothing to notice. Mapping by name absorbs a renumbering + // outright, and a rename or removal becomes a build error. The numeric promise the documentation + // makes is pinned by a test instead, which is the only thing that still depends on it. + private static LoggingLevel ToProtocol(McpMessageLevel level) => + level switch + { + McpMessageLevel.Debug => LoggingLevel.Debug, + McpMessageLevel.Info => LoggingLevel.Info, + McpMessageLevel.Notice => LoggingLevel.Notice, + McpMessageLevel.Warning => LoggingLevel.Warning, + McpMessageLevel.Error => LoggingLevel.Error, + McpMessageLevel.Critical => LoggingLevel.Critical, + McpMessageLevel.Alert => LoggingLevel.Alert, + McpMessageLevel.Emergency => LoggingLevel.Emergency, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, "Unknown message level."), + }; + + private static McpMessageLevel FromProtocol(LoggingLevel level) => + level switch + { + LoggingLevel.Debug => McpMessageLevel.Debug, + LoggingLevel.Info => McpMessageLevel.Info, + LoggingLevel.Notice => McpMessageLevel.Notice, + LoggingLevel.Warning => McpMessageLevel.Warning, + LoggingLevel.Error => McpMessageLevel.Error, + LoggingLevel.Critical => McpMessageLevel.Critical, + LoggingLevel.Alert => McpMessageLevel.Alert, + LoggingLevel.Emergency => McpMessageLevel.Emergency, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, "Unknown protocol logging level."), + }; internal IDisposable PushProgressToken(ProgressToken? progressToken) => new ProgressTokenScope(_progressToken, progressToken); + /// + /// Opens a scope that collects messages the current request cannot receive as notifications. + /// + /// + /// Pushed per invocation by and drained into the tool result, so a + /// client that never asked for log notifications still sees what a command reported. The buffer + /// is rather than a field on + /// because a command can inject directly and bypass the channel. + /// + internal UndeliveredMessageScope PushUndeliveredMessages() => new(_undelivered); + private static JsonElement SerializeData(object? data) => data switch { @@ -89,6 +193,88 @@ private static string BuildProgressMessage(ReplProgressEvent progress) => ? progress.Label : $"{progress.Label}: {progress.Details}"; + /// Messages collected for a request that cannot receive notifications. + internal sealed class UndeliveredMessages + { + private readonly Lock _gate = new(); + private readonly List _lines = []; + + public void Add(McpMessageLevel level, object? data) + { + var text = data as string ?? data?.ToString(); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + lock (_gate) + { + _lines.Add($"[{LevelName(level)}] {text}"); + } + } + + // Literals rather than ToString().ToLowerInvariant(), which allocates twice per message on the + // path the modern revision makes the common one — and a switch rather than an array indexed by + // the enum, for the reason ToProtocol gives above. + private static string LevelName(McpMessageLevel level) => + level switch + { + McpMessageLevel.Debug => "debug", + McpMessageLevel.Info => "info", + McpMessageLevel.Notice => "notice", + McpMessageLevel.Warning => "warning", + McpMessageLevel.Error => "error", + McpMessageLevel.Critical => "critical", + McpMessageLevel.Alert => "alert", + McpMessageLevel.Emergency => "emergency", + _ => level.ToString().ToLowerInvariant(), + }; + + public IReadOnlyList Drain() + { + lock (_gate) + { + if (_lines.Count == 0) + { + return []; + } + + var drained = _lines.ToArray(); + _lines.Clear(); + return drained; + } + } + } + + /// Restores the previous buffer on dispose, so nested invocations stay independent. + internal sealed class UndeliveredMessageScope : IDisposable + { + private readonly AsyncLocal _slot; + private readonly UndeliveredMessages? _previous; + private bool _disposed; + + public UndeliveredMessageScope(AsyncLocal slot) + { + _slot = slot; + _previous = slot.Value; + Messages = new UndeliveredMessages(); + slot.Value = Messages; + } + + public UndeliveredMessages Messages { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _slot.Value = _previous; + _disposed = true; + } + } + private sealed class ProgressTokenScope : IDisposable { private readonly AsyncLocal _progressTokenSlot; diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index ab6531ad..af8bd10a 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -1,10 +1,14 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Interaction; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// @@ -221,7 +225,7 @@ await _server.NotifyProgressAsync( public async ValueTask WriteStatusAsync(string text, CancellationToken cancellationToken) { await SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(text, McpJsonContext.Default.String), cancellationToken) .ConfigureAwait(false); @@ -240,24 +244,24 @@ public ValueTask DispatchAsync( { WriteStatusRequest status => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(status.Text, McpJsonContext.Default.String), cancellationToken)), WriteProgressRequest progress => CompleteBuiltInDispatchAsync( WriteStructuredProgressAsync(progress, cancellationToken)), WriteNoticeRequest notice => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(notice.Text, McpJsonContext.Default.String), cancellationToken)), WriteWarningRequest warning => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Warning, + McpMessageLevel.Warning, JsonSerializer.SerializeToElement(warning.Text, McpJsonContext.Default.String), cancellationToken)), WriteProblemRequest problem => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Error, + McpMessageLevel.Error, SerializeProblem(problem), cancellationToken)), _ => throw new NotSupportedException( @@ -266,30 +270,19 @@ public ValueTask DispatchAsync( } private async ValueTask SendFeedbackAsync( - LoggingLevel level, + McpMessageLevel level, JsonElement data, CancellationToken cancellationToken) { + // Always through IMcpFeedback: it owns the per-request log-level rule (2026-07-28 forbids + // emitting message notifications for a request that did not ask for them) and the buffer that + // carries undeliverable messages back in the tool result. Sending straight to the server here + // would bypass both. It is absent only for the discovery-only channel, which has no server + // to send to either. if (_feedback is not null) { await _feedback.SendMessageAsync(level, data, cancellationToken).ConfigureAwait(false); - return; } - - if (_server is null) - { - return; - } - - await _server.SendNotificationAsync( - NotificationMethods.LoggingMessageNotification, - new LoggingMessageNotificationParams - { - Level = level, - Logger = "repl.interaction", - Data = data, - }, - cancellationToken: cancellationToken).ConfigureAwait(false); } private async ValueTask WriteStructuredProgressAsync( @@ -310,7 +303,7 @@ await _feedback.ReportProgressAsync( if (progress.State == ReplProgressState.Warning) { await _feedback.SendMessageAsync( - LoggingLevel.Warning, + McpMessageLevel.Warning, BuildProgressPayload(progress), cancellationToken) .ConfigureAwait(false); @@ -318,7 +311,7 @@ await _feedback.SendMessageAsync( else if (progress.State == ReplProgressState.Error) { await _feedback.SendMessageAsync( - LoggingLevel.Error, + McpMessageLevel.Error, BuildProgressPayload(progress), cancellationToken) .ConfigureAwait(false); diff --git a/src/Repl.Mcp/McpMessageLevel.cs b/src/Repl.Mcp/McpMessageLevel.cs new file mode 100644 index 00000000..9bae5f76 --- /dev/null +++ b/src/Repl.Mcp/McpMessageLevel.cs @@ -0,0 +1,37 @@ +namespace Repl.Mcp; + +/// +/// Severity of a message sent to the connected MCP client through . +/// +/// +/// This mirrors the protocol's syslog-derived severities. It exists as a Repl-owned type so the +/// public surface does not expose the SDK's LoggingLevel, which the 2026-07-28 +/// specification deprecates (SEP-2577, SDK diagnostic MCP9005): a consumer building with warnings +/// as errors would otherwise fail on a Repl signature it never chose to depend on. +/// +public enum McpMessageLevel +{ + /// Detailed information, useful only when diagnosing a problem. + Debug = 0, + + /// Normal operational information. + Info = 1, + + /// A normal but significant condition. + Notice = 2, + + /// A condition that is not an error but deserves attention. + Warning = 3, + + /// An error that did not prevent the operation from continuing. + Error = 4, + + /// A condition that requires immediate attention. + Critical = 5, + + /// Action must be taken immediately. + Alert = 6, + + /// The system is unusable. + Emergency = 7, +} diff --git a/src/Repl.Mcp/McpProtocolRevisions.cs b/src/Repl.Mcp/McpProtocolRevisions.cs new file mode 100644 index 00000000..1fb79842 --- /dev/null +++ b/src/Repl.Mcp/McpProtocolRevisions.cs @@ -0,0 +1,30 @@ +namespace Repl.Mcp; + +/// +/// MCP protocol revisions Repl reasons about explicitly. +/// +/// +/// The SDK's own McpProtocolVersions class is internal, so the literals have to live here. +/// +internal static class McpProtocolRevisions +{ + /// + /// The last revision built on the initialize handshake, and therefore the last one with + /// protocol-level sessions. + /// + public const string LastWithSessions = "2025-11-25"; + + /// + /// The revision that removed protocol sessions (SEP-2567) and moved per-call state — client + /// capabilities, log level — into per-request _meta (SEP-2575). + /// + public const string Sessionless = "2026-07-28"; + + /// + /// Whether carries the fields introduced with + /// . An initialize-era result schema has no place for them, and a strict + /// client may reject a response that contains one. + /// + public static bool CarriesSessionlessFields(string? protocolVersion) => + string.Equals(protocolVersion, Sessionless, StringComparison.Ordinal); +} diff --git a/src/Repl.Mcp/McpRequestServerAccessor.cs b/src/Repl.Mcp/McpRequestServerAccessor.cs new file mode 100644 index 00000000..7f4bc13c --- /dev/null +++ b/src/Repl.Mcp/McpRequestServerAccessor.cs @@ -0,0 +1,44 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Resolves the a capability call must target, from the flowing request. +/// +/// +/// On the 2026-07-28 revision there is no initialize handshake: the client declares its +/// capabilities per request in _meta, and the SDK surfaces them only on the destination-bound +/// server handed to a handler — is documented as +/// on the root server. Capability resolution is therefore a property of the +/// REQUEST, not of the connection. +/// +/// The whole is bound rather than just its server, because the same +/// per-request metadata carries more than the destination (see the log level in +/// ). flows with the invocation and cannot +/// leak across requests. There is deliberately no session-level fallback: a shared field would hand +/// out the capabilities of whichever connection attached last, which is precisely the cross-wiring +/// this type exists to prevent. +/// +/// +internal sealed class McpRequestServerAccessor +{ + private readonly AsyncLocal _current = new(); + + /// The request currently flowing on this async context, if any. + public MessageContext? Current => _current.Value; + + /// Server for the flowing request, or outside a request. + public McpServer? Effective => _current.Value?.Server; + + /// Binds the flowing async context to the request being served. + public void BindRequest(MessageContext request) => _current.Value = request; + + /// + /// Whether the flowing request belongs to a revision that carries its fields per request rather + /// than establishing a session. + /// + public bool IsSessionlessRequest => + McpProtocolRevisions.CarriesSessionlessFields( + (_current.Value?.JsonRpcMessage as JsonRpcRequest)?.Context?.ProtocolVersion); +} diff --git a/src/Repl.Mcp/McpRootsScope.cs b/src/Repl.Mcp/McpRootsScope.cs new file mode 100644 index 00000000..b8c3e87b --- /dev/null +++ b/src/Repl.Mcp/McpRootsScope.cs @@ -0,0 +1,26 @@ +namespace Repl.Mcp; + +/// +/// How long a resolved set of native client roots may be kept. +/// +/// +/// The 2026-07-28 revision removed protocol sessions, so the two hosting shapes this package +/// supports differ in what identity they can offer. mcp serve creates one context per +/// connection and can cache for that connection's lifetime; a host reusing one +/// BuildMcpServerOptions() result across connections has no per-connection identity at all — +/// the server handed to a handler is destination-bound and constructed per message — so the widest +/// honest scope there is the request. +/// +internal enum McpRootsScope +{ + /// + /// One MCP transport session. Roots are fetched once and kept until the client says they changed. + /// + Connection, + + /// + /// One request. Roots are fetched at most once per request and never outlive it, which is what + /// keeps one client's workspace from reaching another when connections share a service instance. + /// + Request, +} diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 5e6a09e0..568420e7 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -1,28 +1,32 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// /// Internal implementation of backed by a live session. /// -internal sealed class McpSamplingService : IMcpSampling +internal sealed class McpSamplingService(McpRequestServerAccessor servers) : IMcpSampling { - private McpServer? _server; - - public bool IsSupported => _server?.ClientCapabilities?.Sampling is not null; + public bool IsSupported => servers.Effective?.ClientCapabilities?.Sampling is not null; public async ValueTask SampleAsync( string prompt, int maxTokens = 1024, CancellationToken cancellationToken = default) { - if (!IsSupported) + // Single read: the effective server must not change between the support check and + // the call (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { ClientCapabilities.Sampling: not null } server) { return null; } - var result = await _server!.SampleAsync( + var result = await server.SampleAsync( new CreateMessageRequestParams { Messages = @@ -40,5 +44,4 @@ internal sealed class McpSamplingService : IMcpSampling return result.Content?.OfType().FirstOrDefault()?.Text; } - internal void AttachServer(McpServer server) => _server = server; } diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index eaddea03..dc23a339 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using ModelContextProtocol; @@ -27,29 +27,39 @@ internal sealed class McpServerHandler private readonly IServiceProvider _services; private readonly TimeProvider _timeProvider; private readonly char _separator; - private readonly McpClientRootsService _roots; + private readonly McpRequestServerAccessor _requestServers = new(); private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; private readonly McpFeedbackService _feedback; - private readonly IServiceProvider _sessionServices; - private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); + // Context for work that belongs to no MCP session: eager fail-fast validation, the pre-built + // catalog behind BuildMcpServerOptions, and the snapshot test seams. One per handler, sharing its + // lifetime, so nothing disposes it. See McpRootsScope for what that costs anything stored here. + private readonly McpSessionContext _catalogContext; private readonly Lock _refreshLock = new(); private readonly Lock _attachLock = new(); - private McpGeneratedSnapshot? _snapshot; + // Global routing version: bumped by InvalidateRouting for every session; each session's + // context caches the snapshot it built at a given version. private SnapshotVersionState _snapshotState = new(Version: 1, LastVisibilityRetractionVersion: 0); - private long _builtSnapshotVersion; - private McpServer? _server; + private McpSessionContext.SnapshotCacheEntry? _lastGoodSessionlessSnapshot; + // One handler can serve several concurrent sessions; everything session-owned lives in + // McpSessionContext, and this list (guarded by _attachLock) tracks every ACTIVE session + // for server-initiated notifications and subscription lifetime. + private readonly List _sessions = []; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; - private int _rootsNotificationRegistered; - private int _compatibilityIntroServed; private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); - // Notifications are fire-and-forget best-effort — a stuck stdio peer must not hang this - // indefinitely, since nothing awaits it and it would otherwise pile up one task per - // invalidation forever. - private static readonly TimeSpan NotificationSendTimeout = TimeSpan.FromSeconds(5); + // Discovery-change signals. These collections stay EMPTY and never contribute a primitive to a + // list response: they exist only so the SDK's own fan-out runs, because that is the only code + // with access to the subscription registry. On 2026-07-28 it delivers each notification type + // ONLY to clients that requested it through subscriptions/listen, over that request's stream and + // tagged with its id, while still broadcasting session-wide to initialize-era clients. Every + // McpServer built from the options subscribes on construction and unsubscribes on dispose, which + // is what makes one shared instance correct across concurrent connections. + private readonly McpServerPrimitiveCollection _toolListChanged = new(); + private readonly McpServerResourceCollection _resourceListChanged = new(); + private readonly McpServerPrimitiveCollection _promptListChanged = new(); public McpServerHandler( ICoreReplApp app, @@ -61,21 +71,53 @@ public McpServerHandler( _services = services; _timeProvider = services.GetService(typeof(TimeProvider)) as TimeProvider ?? TimeProvider.System; _separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); - _roots = new McpClientRootsService(app); - _sampling = new McpSamplingService(); - _elicitation = new McpElicitationService(); - _feedback = new McpFeedbackService(); - _sessionServices = new McpServiceProviderOverlay( - services, - new Dictionary - { - [typeof(IMcpClientRoots)] = _roots, - [typeof(IMcpSampling)] = _sampling, - [typeof(IMcpElicitation)] = _elicitation, - [typeof(IMcpFeedback)] = _feedback, - }); + // Sampling/elicitation/feedback are stateless (they resolve the request-bound server + // through the accessor) and safely shared; roots and everything else session-owned + // is created per session in CreateSessionContext. + _sampling = new McpSamplingService(_requestServers); + _elicitation = new McpElicitationService(_requestServers); + _feedback = new McpFeedbackService(_requestServers); + _catalogContext = CreateSessionContext(McpRootsScope.Request); + } + + private McpSessionContext CreateSessionContext(McpRootsScope rootsScope) + { + var roots = new McpClientRootsService(_app, _requestServers, rootsScope); + var overlayServices = new Dictionary + { + [typeof(IMcpClientRoots)] = roots, + [typeof(IMcpSampling)] = _sampling, + [typeof(IMcpElicitation)] = _elicitation, + [typeof(IMcpFeedback)] = _feedback, + }; + var context = new McpSessionContext(roots, new McpServiceProviderOverlay(_services, overlayServices)); + // The context rides in its own overlay so request handlers can recover their + // originating session through the server's provider (the dictionary is captured by + // reference, making this two-phase registration safe). + overlayServices[typeof(McpSessionContext)] = context; + return context; } + /// + /// Recovers the session a request belongs to, through the provider handed to + /// McpServer.Create — even a destination-bound per-request server exposes its session's + /// services. + /// + /// + /// A pure lookup on purpose: a miss is a defect, not a case to paper over. Falling back to a + /// shared context would keep _sessions permanently non-empty — so the routing subscription + /// and its debounce timer could never be released — and would latch a destination-bound + /// per-request server as if it were a session server. Nothing reaches here without one anyway: the + /// only server built without a session provider comes from , + /// whose pre-built primitives never route through this handler. + /// + private static McpSessionContext ResolveContext(McpServer? requestServer) => + requestServer?.Services?.GetService(typeof(McpSessionContext)) as McpSessionContext + ?? throw new InvalidOperationException( + "An MCP request reached a Repl handler without its session context. Handlers are only " + + "registered by BuildDynamicServerOptions, whose server is always created with the " + + "session's own service provider."); + [UnconditionalSuppressMessage( "Trimming", "IL2026", @@ -89,8 +131,9 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) : new StdioServerTransport(serverName); try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); + using var context = CreateSessionContext(McpRootsScope.Connection); + var server = McpServer.Create(transport, serverOptions, serviceProvider: context.Services); + AttachSession(context, server); try { @@ -98,7 +141,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - UnsubscribeFromRoutingChanges(); + DetachSession(context); await server.DisposeAsync().ConfigureAwait(false); } } @@ -118,7 +161,9 @@ internal McpServerOptions BuildDynamicServerOptions() // then repeated for the same commands during the first discovery request. if (_options.CommandFilter is null) { - _ = CreateDocumentationModel(); + // No request is flowing at construction, so this warm-up builds the legacy view; the first + // modern request rebuilds for its own era. + _ = CreateDocumentationModel(_catalogContext.Services, sessionless: false); } return new McpServerOptions @@ -135,6 +180,11 @@ internal McpServerOptions BuildDynamicServerOptions() ListPromptsHandler = ListPromptsAsync, GetPromptHandler = GetPromptAsync, }, + // Empty on purpose: collections augment the handlers rather than replace them, so the + // tool graph still comes entirely from the handlers above. See the field declarations. + ToolCollection = _toolListChanged, + ResourceCollection = _resourceListChanged, + PromptCollection = _promptListChanged, }; } @@ -142,7 +192,11 @@ internal McpServerOptions BuildStaticServerOptions() { var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; var serverVersion = _options.ServerVersion ?? "1.0.0"; - var snapshot = BuildSnapshotCore(); + // Built once, before any request, and then shared by every connection — so it cannot vary per + // connection whatever it contains. Built with the modern, invariant view because that is the + // only one that makes it complete: resolving capabilities against no request would silently + // drop every capability-gated module from a catalog that can never be rebuilt. + var snapshot = BuildSnapshotCore(_catalogContext, sessionless: true); return new McpServerOptions { @@ -154,10 +208,11 @@ internal McpServerOptions BuildStaticServerOptions() }; } - internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); + internal McpGeneratedSnapshot BuildSnapshotForTests() => + BuildSnapshotCore(_catalogContext, IsSessionlessRequest()); internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => - await GetSnapshotAsync(server: null, cancellationToken).ConfigureAwait(false); + await GetSnapshotAsync(_catalogContext, cancellationToken).ConfigureAwait(false); private string? ResolveAppName() { @@ -170,35 +225,41 @@ private async ValueTask ListToolsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); + // Legacy only: the shim's first list answers with the bootstrap pair and the next with the real + // catalog, which is a connection-local change caused by another request on that connection — + // exactly what the modern revision forbids. A modern client gets the real catalog immediately. if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim - && Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0) + && !IsSessionlessRequest() + && context.TryClaimCompatibilityIntro()) { - _ = SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification); - return new ListToolsResult + SignalToolListChanged(); + return McpCacheHints.MarkPrivateToThisClient(request, new ListToolsResult { Tools = [ CreateCompatibilityDiscoverTool(), CreateCompatibilityCallTool(), ], - }; + }); } - return new ListToolsResult + return McpCacheHints.MarkPrivateToThisClient(request, new ListToolsResult { Tools = [.. snapshot.Tools.Select(static tool => tool.ProtocolTool)], - }; + }); } private async ValueTask CallToolAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); IDictionary arguments = request.Params.Arguments ?? EmptyArguments; var toolName = request.Params.Name ?? string.Empty; var progressToken = request.Params.ProgressToken; @@ -229,9 +290,10 @@ private async ValueTask ListResourcesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); - return new ListResourcesResult + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); + return McpCacheHints.MarkPrivateToThisClient(request, new ListResourcesResult { Resources = [ @@ -239,16 +301,17 @@ .. snapshot.Resources .Where(static resource => !resource.IsTemplated && resource.ProtocolResource is not null) .Select(static resource => resource.ProtocolResource!), ], - }; + }); } private async ValueTask ListResourceTemplatesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); - return new ListResourceTemplatesResult + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); + return McpCacheHints.MarkPrivateToThisClient(request, new ListResourceTemplatesResult { ResourceTemplates = [ @@ -256,15 +319,16 @@ .. snapshot.Resources .Where(static resource => resource.IsTemplated) .Select(static resource => resource.ProtocolResourceTemplate), ], - }; + }); } private async ValueTask ReadResourceAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); var uri = request.Params.Uri ?? string.Empty; var resource = snapshot.Resources.FirstOrDefault(candidate => candidate.IsMatch(uri)); if (resource is null) @@ -279,20 +343,22 @@ private async ValueTask ListPromptsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); - return new ListPromptsResult + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); + return McpCacheHints.MarkPrivateToThisClient(request, new ListPromptsResult { Prompts = [.. snapshot.Prompts.Select(static prompt => prompt.ProtocolPrompt)], - }; + }); } private async ValueTask GetPromptAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); var promptName = request.Params.Name ?? string.Empty; var prompt = snapshot.Prompts.FirstOrDefault(candidate => string.Equals(candidate.ProtocolPrompt.Name, promptName, StringComparison.OrdinalIgnoreCase)); @@ -304,33 +370,41 @@ private async ValueTask GetPromptAsync( return await prompt.GetAsync(request, cancellationToken).ConfigureAwait(false); } + // The snapshot is SESSION state: the tool graph can be gated on session capabilities + // (roots, module presence predicates), so each context caches its own build against + // the handler-global routing version. private async ValueTask GetSnapshotAsync( - McpServer? server, + McpSessionContext context, CancellationToken cancellationToken) { - AttachServer(server); - + var sessionless = IsSessionlessRequest(); var snapshotVersion = Volatile.Read(ref _snapshotState).Version; - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } cached) + if (context.SnapshotCache is { IsStale: false } cached + && cached.Version == snapshotVersion + && cached.Sessionless == sessionless) { - return cached; + return cached.Snapshot; } - await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await context.SnapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { snapshotVersion = Volatile.Read(ref _snapshotState).Version; - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } refreshed) + if (context.SnapshotCache is { IsStale: false } refreshed + && refreshed.Version == snapshotVersion + && refreshed.Sessionless == sessionless) { - return refreshed; + return refreshed.Snapshot; } - var previousSnapshot = _snapshot; + var previousEntry = context.SnapshotCache; + var previousSnapshot = previousEntry?.Snapshot; try { - return await BuildCurrentSnapshotAsync(snapshotVersion, cancellationToken).ConfigureAwait(false); + var built = await BuildCurrentSnapshotAsync(context, snapshotVersion, sessionless, cancellationToken) + .ConfigureAwait(false); + RememberSharedFallback(built, snapshotVersion, sessionless); + return built; } catch (OperationCanceledException) { @@ -342,19 +416,20 @@ private async ValueTask GetSnapshotAsync( throw; } catch (Exception) when ( - previousSnapshot is not null - && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion - <= Volatile.Read(ref _builtSnapshotVersion)) + ResolveFallback(context, sessionless) is not null) { - // Preserve availability for transient projection failures, but leave the version dirty - // so the next request retries without requiring another routing mutation. - _snapshot = previousSnapshot; - return previousSnapshot; + // Preserve availability for transient projection failures, but republish as stale so the + // next request retries without requiring another routing mutation. The entry keeps the + // version it was built at, because the retraction comparison reads it: a sentinel version + // would count as older than every retraction and take the fallback away after the first. + var fallback = ResolveFallback(context, sessionless)!; + context.PublishStaleSnapshot(fallback.Snapshot, fallback.Version, fallback.Sessionless); + return fallback.Snapshot; } } finally { - _snapshotGate.Release(); + context.SnapshotGate.Release(); } } @@ -377,14 +452,25 @@ private static void ThrowSanitizedIfAClientAlreadyHasASchema(McpGeneratedSnapsho } private async ValueTask BuildCurrentSnapshotAsync( + McpSessionContext context, long snapshotVersion, + bool sessionless, CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); - await _roots.GetAsync(cancellationToken).ConfigureAwait(false); - var built = BuildSnapshotCore(); + + // Legacy only. The pre-resolution exists so a session-gated graph sees this client's roots + // before the predicates run; on a modern revision discovery never consults them, so doing it + // there would send a roots/list to the client as a side effect of a tools/list, for a result + // nothing reads. Execution still resolves roots per request. + if (!sessionless) + { + await context.Roots.GetAsync(cancellationToken).ConfigureAwait(false); + } + + var built = BuildSnapshotCore(context, sessionless); var observedState = Volatile.Read(ref _snapshotState); // Version and retraction watermark are one atomically published state. A reader can @@ -396,32 +482,40 @@ private async ValueTask BuildCurrentSnapshotAsync( continue; } - _snapshot = built; + // One publication either way: a build that raced a routing bump is still serve-able, but + // is marked stale so the next request rebuilds it. if (observedState.Version == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.PublishSnapshot(built, snapshotVersion, sessionless); + } + else + { + context.PublishStaleSnapshot(built, snapshotVersion, sessionless); } return built; } } - private McpGeneratedSnapshot BuildSnapshotCore() + private McpGeneratedSnapshot BuildSnapshotCore(McpSessionContext context, bool sessionless) { // Project once here so tools/list, tools/call and prompts/list all read the same option list. - var model = McpAutomationProjection.Apply(CreateDocumentationModel()); - var adapter = new McpToolAdapter(_app, _options, _sessionServices); + var model = McpAutomationProjection.Apply(CreateDocumentationModel(context.Services, sessionless)); + var adapter = new McpToolAdapter(_app, _options, context.Services, _requestServers, sessionless); var commandsByPath = model.Commands.ToDictionary( command => command.Path, command => command, StringComparer.OrdinalIgnoreCase); var tools = GenerateAllTools(model, adapter, _separator, commandsByPath); ValidateCompatibilityToolNames(tools); - var resources = GenerateResources(model, adapter, _separator, commandsByPath); - var prompts = CollectPrompts(model, adapter, _separator); + var resources = GenerateResources(model, adapter, _separator, commandsByPath, context.Services); + var prompts = CollectPrompts(model, adapter, _separator, context.Services); return new McpGeneratedSnapshot(adapter, tools, resources, prompts); } - private ReplDocumentationModel CreateDocumentationModel() + // The documentation model resolves module-presence predicates against the SESSION's + // services (e.g. IMcpClientRoots), so the model — and everything generated from it — + // reflects the capabilities of the session it is built for. + private ReplDocumentationModel CreateDocumentationModel(IServiceProvider sessionServices, bool sessionless) { var coreApp = _app as CoreReplApp ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); @@ -430,7 +524,9 @@ private ReplDocumentationModel CreateDocumentationModel() ReplSessionIO.IsProgrammatic = true; try { - return coreApp.CreateDocumentationModel(CreateDiscoveryServices(), IsMcpCandidateBeforeValidation); + return coreApp.CreateDocumentationModel( + CreateDiscoveryServices(sessionServices, sessionless), + IsMcpCandidateBeforeValidation); } finally { @@ -441,15 +537,40 @@ private ReplDocumentationModel CreateDocumentationModel() // Every MCP tool invocation overlays a concrete interaction channel before entering the // binder. Discovery must expose that guaranteed fallback even when the caller did not supply // a base provider (or supplied one without the channel). - private McpServiceProviderOverlay CreateDiscoveryServices() => - new( - _sessionServices, - new Dictionary + private McpServiceProviderOverlay CreateDiscoveryServices( + IServiceProvider sessionServices, + bool sessionless) + { + var overlay = new Dictionary + { + [typeof(IReplInteractionChannel)] = new McpInteractionChannel( + new Dictionary(StringComparer.Ordinal), + _options.InteractivityMode), + }; + + if (sessionless) + { + // On a modern revision the advertised set must not vary per connection nor as a side effect + // of another request, so discovery sees constants that reach no live service at all. It is + // not only the capability services: a presence predicate receives whatever it declares, and + // session state is a mutable singleton shared with execution — leaving it live would let a + // tools/call decide what the next tools/list advertises. Execution keeps the real services + // for binding, and takes these same answers for deciding presence. + foreach (var (type, service) in McpDiscoveryCapabilities.CreateSessionScopedOverrides()) { - [typeof(IReplInteractionChannel)] = new McpInteractionChannel( - new Dictionary(StringComparer.Ordinal), - _options.InteractivityMode), - }); + overlay[type] = service; + } + } + + return new McpServiceProviderOverlay(sessionServices, overlay); + } + + /// + /// Whether the request being served belongs to the modern, sessionless era. Modern requests carry + /// their protocol version in _meta; a legacy session negotiated it once at initialize + /// and carries none, which reads as legacy here. + /// + private bool IsSessionlessRequest() => _requestServers.IsSessionlessRequest; private void ValidateCompatibilityToolNames(IReadOnlyList tools) { @@ -470,28 +591,67 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) } } - private void AttachServer(McpServer? server) + // Request-level binding: capability services resolve the flowing request through the AsyncLocal + // accessor, so concurrent requests (SDK 2.0 creates one destination-bound McpServer per request) + // cannot cross-wire each other's client capabilities. The whole request is bound, not just its + // server, because 2026-07-28 carries the client's capabilities and log level in per-request + // _meta. Session-level concerns are handled by AttachSession (RunAsync). + private void BindRequest(MessageContext request) => _requestServers.BindRequest(request); + + // Session-level attach: routing-change notifications and the roots list-changed + // handler belong to the session servers, registered once per session — never to the + // per-request destination wrappers. + private void AttachSession(McpSessionContext context, McpServer server) { - if (server is null) + lock (_attachLock) { - return; + _sessions.Add(context); + EnsureRoutingSubscription(); + EnsureRootsNotificationHandler(server, context.Roots); } + } - lock (_attachLock) + // The fallback every modern connection shares, so a transient failure cannot leave two of them + // serving different sets. The initialize era keeps using each connection's own previous catalog. + private void RememberSharedFallback(McpGeneratedSnapshot built, long version, bool sessionless) + { + if (sessionless) { - if (ReferenceEquals(_server, server)) - { - return; - } + Volatile.Write( + ref _lastGoodSessionlessSnapshot, + new McpSessionContext.SnapshotCacheEntry(built, version, IsStale: false, Sessionless: true)); + } + } - _server = server; - _roots.AttachServer(server); - _sampling.AttachServer(server); - _elicitation.AttachServer(server); - _feedback.AttachServer(server); - EnsureRoutingSubscription(); - EnsureRootsNotificationHandler(server); + /// + /// What a failed projection may serve instead, or when nothing may. + /// + /// + /// Availability is preserved on both revisions, but not from the same place. The initialize era + /// falls back to the connection's own previous catalog, which is what that revision serves anyway. + /// On 2026-07-28 the advertised set must not vary per connection, and a per-session fallback + /// is precisely how it would: one connection would keep its previous catalog while another, whose + /// build succeeded or which connected later, serves the new one. Modern connections therefore share + /// one last-known-good catalog, so a transient failure moves all of them together or none. + /// + /// Either way a catalog retracted for visibility is never re-served: that failure has to fail + /// closed, since the retraction is the whole point. + /// + /// + private McpSessionContext.SnapshotCacheEntry? ResolveFallback(McpSessionContext context, bool sessionless) + { + var candidate = sessionless + ? Volatile.Read(ref _lastGoodSessionlessSnapshot) + : context.SnapshotCache; + + if (candidate is null || candidate.Sessionless != sessionless) + { + return null; } + + return Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion <= candidate.Version + ? candidate + : null; } internal sealed record SnapshotVersionState( @@ -522,25 +682,38 @@ private void EnsureRoutingSubscription() coreApp.RoutingInvalidatedDetailed += handler; } - private void EnsureRootsNotificationHandler(McpServer server) + // Reference-counted on purpose: the routing subscription is dropped only when the LAST session + // ends, because a first-session close must not silence the others. + private void DetachSession(McpSessionContext context) { - if (Interlocked.Exchange(ref _rootsNotificationRegistered, 1) != 0) + lock (_attachLock) { - return; + _sessions.Remove(context); + if (_sessions.Count == 0) + { + UnsubscribeFromRoutingChanges(); + } } + } - var weakSelf = new WeakReference(this); + private static void EnsureRootsNotificationHandler(McpServer server, McpClientRootsService roots) + { + var weakSelf = new WeakReference(roots); + // Roots is deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but hosts still send + // this notification; Repl keeps supporting it until the SDK removes the surface (#51). +#pragma warning disable MCP9005 _ = server.RegisterNotificationHandler( NotificationMethods.RootsListChangedNotification, (_, _) => { if (weakSelf.TryGetTarget(out var target)) { - target._roots.HandleRootsListChanged(); + target.HandleRootsListChanged(); } return ValueTask.CompletedTask; }); +#pragma warning restore MCP9005 } internal static SnapshotVersionState PublishSnapshotInvalidation( @@ -574,49 +747,41 @@ private void OnRoutingInvalidated(bool isVisibilityRetraction) if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) { - Interlocked.Exchange(ref _compatibilityIntroServed, 0); + // Every active session re-serves its compatibility intro after a routing change. + lock (_attachLock) + { + foreach (var session in _sessions) + { + session.ResetCompatibilityIntro(); + } + } } lock (_refreshLock) { _debounceTimer?.Dispose(); _debounceTimer = _timeProvider.CreateTimer( - _ => _ = SendDiscoveryNotificationsSafeAsync(), + _ => SignalDiscoveryChanged(), state: null, dueTime: DebounceDelay, period: Timeout.InfiniteTimeSpan); } } - private async Task SendDiscoveryNotificationsSafeAsync() + private void SignalDiscoveryChanged() { - await SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification).ConfigureAwait(false); - await SendNotificationSafeAsync(NotificationMethods.ResourceListChangedNotification).ConfigureAwait(false); - await SendNotificationSafeAsync(NotificationMethods.PromptListChangedNotification).ConfigureAwait(false); + _toolListChanged.Clear(); + _resourceListChanged.Clear(); + _promptListChanged.Clear(); } - private async Task SendNotificationSafeAsync(string method) - { - try - { - var server = _server; - if (server is null) - { - return; - } - - using var timeoutCts = new CancellationTokenSource(NotificationSendTimeout); - await server.SendNotificationAsync(method, timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Notifications are best-effort. Cancellation is not actionable here. - } - catch (Exception) - { - // Notifications are best-effort. The next list/read request will rebuild on demand. - } - } + // Clearing an already-empty primitive collection raises its Changed event without mutating + // anything, which is what lets an empty collection act as a pure signal. That the event fires + // unconditionally is NOT documented on Clear(), so it is pinned by + // Given_McpSubscriptions.When_ClearingAnEmptyCollection_Then_ChangedStillFires: if a future SDK + // turns Clear() into a no-op, that test fails loudly instead of discovery notifications silently + // disappearing. + private void SignalToolListChanged() => _toolListChanged.Clear(); private void UnsubscribeFromRoutingChanges() { @@ -635,6 +800,10 @@ private void UnsubscribeFromRoutingChanges() private ServerCapabilities BuildCapabilities() { + // Logging is deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but the feedback + // bridge still routes through logging notifications for current hosts; Repl keeps + // advertising it until the SDK removes the surface (#51). +#pragma warning disable MCP9005 var capabilities = new ServerCapabilities { Logging = new LoggingCapability(), @@ -642,6 +811,7 @@ private ServerCapabilities BuildCapabilities() Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, }; +#pragma warning restore MCP9005 if (_options.EnableApps || HasMcpAppResources()) { @@ -661,6 +831,21 @@ private ServerCapabilities BuildCapabilities() return capabilities; } + /// + /// Whether any catalog this handler can build contains an MCP App resource. + /// + /// + /// Answered from registration, not from a resolution. Capabilities are declared once, before any + /// request names an era or a caller, while the catalog that reaches a client is resolved later and + /// per connection — a real initialize-era snapshot resolves the client's roots before evaluating + /// presence predicates, so an App gated on the roots data belongs to a catalog no requestless + /// evaluation can predict. Advertising the extension for a catalog that ends up without an App + /// costs nothing; omitting it for one that has it leaves the client holding metadata it cannot + /// interpret. Being blind to the predicates is also what keeps this correct if presence ever comes + /// to vary by caller (#97): the answer does not depend on why the graph varies. Registration is read + /// undeduplicated for the same reason — a template registered twice resolves to one route, and the + /// shadowed one is served by every resolution that excludes the shadowing module. + /// private bool HasMcpAppResources() { if (_options.UiResources.Count > 0) @@ -670,32 +855,13 @@ private bool HasMcpAppResources() var coreApp = _app as CoreReplApp ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); - var previousProgrammatic = ReplSessionIO.IsProgrammatic; - ReplSessionIO.IsProgrammatic = true; - try - { - using var runtimeStateScope = coreApp.PushRuntimeState( - CreateDiscoveryServices(), - isInteractiveSession: false); - var activeGraph = coreApp.ResolveActiveRoutingGraph(); - var commands = coreApp.ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - Array.Empty(), - StringComparison.OrdinalIgnoreCase); - - // This capability probe historically ignores CommandFilter. Inspect route metadata directly - // so the filter remains a once-per-snapshot predicate and excluded invalid CLI routes are not - // documented or validated merely to decide whether the optional Apps extension is advertised. - return commands.Any(static route => - !route.Command.IsHidden - && (route.Command.Metadata.ContainsKey(McpAppMetadata.ResourceMetadataKey) - || route.Command.Metadata.ContainsKey(McpAppMetadata.CommandMetadataKey))); - } - finally - { - ReplSessionIO.IsProgrammatic = previousProgrammatic; - } + + // Route metadata directly, as this probe has always done for CommandFilter: deciding whether to + // advertise an optional extension must not document or validate anything. + return coreApp.AnyRegisteredRoute(static route => + !route.Command.IsHidden + && (route.Command.Metadata.ContainsKey(McpAppMetadata.ResourceMetadataKey) + || route.Command.Metadata.ContainsKey(McpAppMetadata.CommandMetadataKey))); } private static Tool CreateCompatibilityDiscoverTool() => new() @@ -941,7 +1107,8 @@ private List GenerateResources( ReplDocumentationModel model, McpToolAdapter adapter, char separator, - Dictionary commandsByPath) + Dictionary commandsByPath, + IServiceProvider sessionServices) { var resources = new List(); var resourceMimeType = adapter.ForcedOutputMimeType; @@ -994,7 +1161,7 @@ private List GenerateResources( foreach (var uiResource in _options.UiResources) { - resources.Add(new McpAppResource(uiResource, _sessionServices)); + resources.Add(new McpAppResource(uiResource, sessionServices, _requestServers)); } return resources; @@ -1021,7 +1188,8 @@ private static bool TryGetAppResourceOptions( private List CollectPrompts( ReplDocumentationModel model, McpToolAdapter adapter, - char separator) + char separator, + IServiceProvider services) { var prompts = new Dictionary(StringComparer.OrdinalIgnoreCase); var promptSources = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1049,9 +1217,12 @@ private List CollectPrompts( foreach (var registration in _options.Prompts) { - prompts[registration.Name] = McpServerPrompt.Create( - registration.Handler, - new McpServerPromptCreateOptions { Name = registration.Name }); + prompts[registration.Name] = new McpExplicitPrompt( + McpServerPrompt.Create( + registration.Handler, + new McpServerPromptCreateOptions { Name = registration.Name, Services = services }), + services, + _requestServers); } return [.. prompts.Values]; diff --git a/src/Repl.Mcp/McpServiceProviderOverlay.cs b/src/Repl.Mcp/McpServiceProviderOverlay.cs index 1dcf0e92..999b6446 100644 --- a/src/Repl.Mcp/McpServiceProviderOverlay.cs +++ b/src/Repl.Mcp/McpServiceProviderOverlay.cs @@ -1,14 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; + namespace Repl.Mcp; /// /// Service provider overlay that injects MCP-specific services. /// +/// +/// It answers as well as , +/// because a consumer that asks whether a type is available before asking for it would otherwise never +/// see what this overlay adds. The MCP SDK does exactly that when it decides whether a handler +/// 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. +/// internal sealed class McpServiceProviderOverlay( IServiceProvider inner, - IReadOnlyDictionary overrides) : IServiceProvider + IReadOnlyDictionary overrides) : IServiceProvider, IServiceProviderIsService { public object? GetService(Type serviceType) { + if (serviceType == typeof(IServiceProviderIsService)) + { + return this; + } + if (overrides.TryGetValue(serviceType, out var service)) { return service; @@ -16,4 +31,8 @@ internal sealed class McpServiceProviderOverlay( return inner.GetService(serviceType); } + + public bool IsService(Type serviceType) => + overrides.ContainsKey(serviceType) + || (inner.GetService(typeof(IServiceProviderIsService)) as IServiceProviderIsService)?.IsService(serviceType) == true; } diff --git a/src/Repl.Mcp/McpSessionContext.cs b/src/Repl.Mcp/McpSessionContext.cs new file mode 100644 index 00000000..948431b3 --- /dev/null +++ b/src/Repl.Mcp/McpSessionContext.cs @@ -0,0 +1,104 @@ +namespace Repl.Mcp; + +/// +/// State owned by one MCP transport session, or — on the reusable-options path — by the handler +/// itself, standing in for a session that the protocol no longer provides. +/// +/// +/// One can serve several concurrent sessions, so anything +/// that varies per client lives here instead of on the handler: hard/soft roots, the +/// generated snapshot cache (the tool graph can be gated on session capabilities), the +/// compatibility-shim intro state, and the session's service overlay. The context is +/// registered in the provider passed to McpServer.Create, so request handlers +/// recover their originating session through request.Server.Services — never +/// through a destination-bound per-request server used as a surrogate session key. +/// Request-bound OUTBOUND capabilities (sampling, elicitation, progress) keep flowing +/// through the per-request binding, which is +/// finer-grained than the session. +/// +internal sealed class McpSessionContext : IDisposable +{ + private SnapshotCacheEntry? _snapshotCache; + private int _compatibilityIntroServed; + + public McpSessionContext(McpClientRootsService roots, IServiceProvider services) + { + Roots = roots; + Services = services; + } + + /// Session-owned hard/soft roots. + public McpClientRootsService Roots { get; } + + /// Per-session service overlay handed to McpServer.Create. + public IServiceProvider Services { get; } + + /// Serializes snapshot builds for this session. + public SemaphoreSlim SnapshotGate { get; } = new(initialCount: 1, maxCount: 1); + + /// + /// Cached snapshot paired with the routing version it was built at, or + /// before this session's first build. + /// + public SnapshotCacheEntry? SnapshotCache => Volatile.Read(ref _snapshotCache); + + /// Publishes as current for . + public void PublishSnapshot( + McpServerHandler.McpGeneratedSnapshot snapshot, + long version, + bool sessionless) => + Volatile.Write(ref _snapshotCache, new SnapshotCacheEntry(snapshot, version, IsStale: false, sessionless)); + + /// + /// Publishes , built at , as serve-able but + /// stale, so the next request rebuilds without waiting for another routing mutation. + /// + public void PublishStaleSnapshot( + McpServerHandler.McpGeneratedSnapshot snapshot, + long version, + bool sessionless) => + Volatile.Write(ref _snapshotCache, new SnapshotCacheEntry(snapshot, version, IsStale: true, sessionless)); + + /// + /// Claims this session's one-time compatibility-shim intro; for the first + /// caller only. + /// + public bool TryClaimCompatibilityIntro() => + Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0; + + /// Re-arms the compatibility-shim intro after a routing invalidation. + public void ResetCompatibilityIntro() => Interlocked.Exchange(ref _compatibilityIntroServed, 0); + + public void Dispose() => SnapshotGate.Dispose(); + + /// + /// A generated snapshot and the routing version it was built at, published as ONE value. + /// + /// + /// Held as two independent fields, a lock-free reader could observe the fresh version paired with + /// the previous snapshot and serve stale discovery state; one reference swapped with + /// release/acquire semantics removes the ordering question altogether. Writers are serialized by + /// , so a plain Volatile.Write suffices — unlike + /// , which races several threads and + /// therefore needs a compare-and-swap loop. + /// + /// The generated tool, resource and prompt graph. + /// The routing version this snapshot was built at. + /// Whether the next request must rebuild rather than serve this again. + /// + /// The protocol era this snapshot was built for. It is part of the key rather than an attribute, + /// because the two eras see different command graphs: a modern build answers every capability + /// question "supported" so the advertised set cannot vary per connection, while a legacy build + /// reflects the session's own capabilities. One connection really does reach this cache with both: + /// the SDK accepts a modern per-request call and then an initialize handshake on the same + /// pipe, which is what the specification means by a dual-era server. Without the era in the key the + /// second request is served the first one's catalog — see + /// When_OneConnectionIsServedBothEras_Then_EachGetsItsOwnCatalog. + /// + internal sealed record SnapshotCacheEntry( + McpServerHandler.McpGeneratedSnapshot Snapshot, + long Version, + bool IsStale, + bool Sessionless) +; +} diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 5e79bf0a..678e3edc 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -1,5 +1,6 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl; @@ -22,16 +23,40 @@ internal sealed partial class McpToolAdapter private readonly ICoreReplApp _app; private readonly ReplMcpServerOptions _options; private readonly IServiceProvider _services; + private readonly McpRequestServerAccessor _requestServers; + // Whether the catalog this adapter serves was built from the frozen discovery view. A property of + // the catalog, not of the request: a reusable BuildMcpServerOptions() result is frozen once and then + // serves clients of either era, so asking the request would leave an initialize-era caller unable to + // run what that catalog offered it. + private readonly bool _catalogIsFrozen; private readonly System.Collections.Concurrent.ConcurrentDictionary _toolRoutes = new(StringComparer.OrdinalIgnoreCase); private readonly System.Collections.Concurrent.ConcurrentDictionary _staticToolResults = new(StringComparer.OrdinalIgnoreCase); - public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServiceProvider services) + public McpToolAdapter( + ICoreReplApp app, + ReplMcpServerOptions options, + IServiceProvider services, + McpRequestServerAccessor requestServers, + bool catalogIsFrozen = false) { _app = app; _options = options; _services = services; + _requestServers = requestServers; + _catalogIsFrozen = catalogIsFrozen; } + /// + /// Binds the flowing async context to before dispatching a command. + /// + /// + /// The pre-built primitives ( and friends) are dispatched straight + /// by the SDK on the BuildMcpServerOptions path, bypassing 's + /// request handlers entirely. Without this, capability services resolved from DI would have no + /// request to resolve against and would report every client capability as unavailable. + /// + internal void BindRequest(MessageContext request) => _requestServers.BindRequest(request); + internal string ForcedOutputMimeType { get @@ -121,7 +146,7 @@ public async Task InvokeAsync( var (tokens, prefills) = PrepareExecution(command, arguments); var invocation = await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) .ConfigureAwait(false); - var output = invocation.Output; + var output = invocation.ExitCode == 0 ? invocation.Output : DescribeFailure(invocation); if (string.IsNullOrWhiteSpace(output)) { output = invocation.ExitCode == 0 @@ -129,7 +154,8 @@ public async Task InvokeAsync( : $"Command failed with exit code {invocation.ExitCode}."; } - return BuildToolResult(output, invocation.ExitCode, _options.PagedResultTextMode); + return BuildToolResult( + output, invocation.ExitCode, _options.PagedResultTextMode, invocation.UndeliveredMessages); } internal async Task InvokeResourceAsync( @@ -156,18 +182,12 @@ internal async Task InvokeResourceAsync( if (invocation.ExitCode != 0) { - var error = invocation.Output; - if (string.IsNullOrWhiteSpace(error)) - { - error = invocation.Error; - } - - if (string.IsNullOrWhiteSpace(error)) - { - error = $"Command failed with exit code {invocation.ExitCode}."; - } - - return new McpResourceReadInvocation(error, TextPlainMimeType, IsError: true); + // A failed read has no body to carry what the command reported — the success path's body has + // to match the advertised MIME type — so the surfaced error is the only place left for it. + return new McpResourceReadInvocation( + AppendMessages(DescribeFailure(invocation), invocation.UndeliveredMessages), + TextPlainMimeType, + IsError: true); } if (string.IsNullOrWhiteSpace(invocation.Output)) @@ -191,6 +211,8 @@ private async Task ExecuteThroughPipelineAsync( var invocableApp = _app as ISubInvocableReplApp ?? throw new InvalidOperationException("MCP tool adapter requires an app that supports sub-invocation."); + await McpClientRootsService.PrimeFromServicesAsync(_services, ct).ConfigureAwait(false); + var outputWriter = new StringWriter(); var errorWriter = captureCommandOutput ? outputWriter : new StringWriter(); var inputReader = new StringReader(string.Empty); @@ -203,8 +225,11 @@ private async Task ExecuteThroughPipelineAsync( { [typeof(IReplInteractionChannel)] = interactionChannel, }); - using var feedbackScope = (_services.GetService(typeof(IMcpFeedback)) as McpFeedbackService) - ?.PushProgressToken(progressToken); + var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; + using var feedbackScope = feedbackService?.PushProgressToken(progressToken); + // Messages the client cannot receive as notifications ride back in the tool result instead, + // so no feedback is lost on a request that never asked for log notifications. + using var undeliveredScope = feedbackService?.PushUndeliveredMessages(); // Force JSON output — agents consume structured data, not human tables/banners. var effectiveTokens = new List(tokens.Count + 1) { $"--output:{ForcedOutputFormat}" }; @@ -224,26 +249,95 @@ private async Task ExecuteThroughPipelineAsync( { ReplSessionIO.IsProgrammatic = true; using var invocationContract = ReplSessionIO.PushProgrammaticInvocationContract(ProgrammaticInvocationContractVersion); - var exitCode = await invocableApp.RunSubInvocationAsync( - effectiveTokens.ToArray(), mcpServices, ct).ConfigureAwait(false); + // Presence is decided from the same answers that built this catalog, so what was advertised + // is what can be called. Binding keeps the live services, so the command that runs still sees + // the real client and can report what it is missing. A catalog resolved per session has + // nothing to reconcile: it was built from the live view and may vary with it. + var presenceServices = _catalogIsFrozen + ? new McpServiceProviderOverlay(mcpServices, McpDiscoveryCapabilities.CreateSessionScopedOverrides()) + : null; + var completed = await invocableApp.RunSubInvocationWithOutcomeAsync( + effectiveTokens.ToArray(), mcpServices, presenceServices, ct).ConfigureAwait(false); var output = outputWriter.ToString().Trim(); var error = captureCommandOutput ? string.Empty : errorWriter.ToString().Trim(); - return new McpPipelineInvocation(output, error, exitCode); + var undelivered = undeliveredScope?.Messages.Drain() ?? []; + return new McpPipelineInvocation( + output, error, completed.ExitCode, completed.Kind, completed.Failure, undelivered); } } internal readonly record struct McpResourceReadInvocation(string Text, string MimeType, bool IsError); - private readonly record struct McpPipelineInvocation(string Output, string Error, int ExitCode); + private readonly record struct McpPipelineInvocation( + string Output, + string Error, + int ExitCode, + ReplExecutionOutcomeKind Kind, + Exception? Failure, + IReadOnlyList UndeliveredMessages); - private static CallToolResult BuildToolResult(string output, int exitCode, McpPagedResultTextMode pagedTextMode) + /// + /// What a failed run is allowed to tell the client. + /// + /// + /// The line is not the kind of failure but who the text was written for. A handler that returns a + /// failure result, and the framework's own refusals — unknown command, a binding diagnostic naming + /// what could not be converted — were all authored for whoever called, so they travel unchanged; an + /// agent uses them to correct itself. What the framework rendered from an exception that escaped + /// code nobody meant to surface does not: such messages routinely carry a filesystem path, a + /// parameter and its full CLR type, or a connection string. On a console the reader is the + /// operator; over MCP it is a remote client, which is the whole difference. + /// + /// Two exceptions are themselves addressed to the client and keep their message: an + /// , which the SDK passes through verbatim precisely because raising one + /// is deliberate, and an , which exists to tell the caller + /// which answer it failed to supply. Withholding those would leave a documented interaction mode + /// unable to say what it needs. + /// + /// + private static string DescribeFailure(in McpPipelineInvocation invocation) + { + var withheld = $"Command failed with exit code {invocation.ExitCode}."; + if (WithholdsFailureText(invocation)) + { + return withheld; + } + + if (!string.IsNullOrWhiteSpace(invocation.Output)) + { + return invocation.Output; + } + + return string.IsNullOrWhiteSpace(invocation.Error) ? withheld : invocation.Error; + } + + private static bool WithholdsFailureText(in McpPipelineInvocation invocation) + { + // Marked at the binder because nothing downstream can tell application code failing from a + // diagnostic the binder wrote itself — same outcome kind, commonly the same exception type. + if (invocation.Failure is ReplBindingCallbackException) + { + return true; + } + + return invocation.Kind is ReplExecutionOutcomeKind.HandlerException + && invocation.Failure is not (McpException or McpInteractionException); + } + + private static CallToolResult BuildToolResult( + string output, + int exitCode, + McpPagedResultTextMode pagedTextMode, + IReadOnlyList undeliveredMessages) { if (exitCode == 0 && TryCreatePagedStructuredResult(output, out var structuredContent, out var summary)) { return new CallToolResult { - Content = [new TextContentBlock { Text = BuildPagedTextContent(output, summary, pagedTextMode) }], + Content = WithMessages( + new TextContentBlock { Text = BuildPagedTextContent(output, summary, pagedTextMode) }, + undeliveredMessages), StructuredContent = structuredContent, IsError = false, }; @@ -251,11 +345,90 @@ private static CallToolResult BuildToolResult(string output, int exitCode, McpPa return new CallToolResult { - Content = [new TextContentBlock { Text = output }], + Content = WithMessages(new TextContentBlock { Text = output }, undeliveredMessages), IsError = exitCode != 0, }; } + /// + /// Flattens a failed invocation's text blocks into one message: the payload, unwrapped, followed by + /// any feedback the client could not receive as a notification. + /// + /// + /// The inverse of , for the two callers that surface a failure as an + /// exception rather than a result. A failure is where those messages matter most — they are usually + /// what explains it — and there is no content array left to put them in. + /// + internal static string BuildErrorMessage(IReadOnlyList blocks, string fallback) + { + if (blocks.Count == 0) + { + return fallback; + } + + var primary = McpJsonStringOutput.UnwrapJsonStringLiteral(blocks[0].Text); + if (blocks.Count == 1) + { + return primary; + } + + var message = new System.Text.StringBuilder(primary); + for (var i = 1; i < blocks.Count; i++) + { + message.Append(Environment.NewLine).Append(blocks[i].Text); + } + + return message.ToString(); + } + + /// + /// Appends after , one per line, returning + /// unchanged when there is nothing to append. + /// + internal static string AppendMessages(string primary, IReadOnlyList messages) + { + if (messages.Count == 0) + { + return primary; + } + + var builder = new System.Text.StringBuilder(primary); + foreach (var message in messages) + { + builder.Append(Environment.NewLine).Append(message); + } + + return builder.ToString(); + } + + /// + /// Appends messages the client could not receive as notifications, as a trailing content block. + /// + /// + /// The command's own payload stays the first block (and StructuredContent is untouched), so + /// a caller reading the primary result is unaffected. Only requests that asked for no log level + /// carry anything here — a client receiving message notifications would otherwise see each one + /// twice. Resource reads deliberately get no such block: their body must match the advertised + /// MIME type. + /// + private static List WithMessages( + TextContentBlock primary, + IReadOnlyList undeliveredMessages) + { + if (undeliveredMessages.Count == 0) + { + return [primary]; + } + + var blocks = new List(undeliveredMessages.Count + 1) { primary }; + foreach (var message in undeliveredMessages) + { + blocks.Add(new TextContentBlock { Text = message }); + } + + return blocks; + } + private static string BuildPagedTextContent( string serializedPage, string summary, diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 06ee966b..3f852ea8 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -6,6 +6,32 @@ MCP server integration for [Repl Toolkit](https://github.com/yllibed/repl) — e Use `Repl.Mcp` when you already have, or want to build, a Repl command graph and make the same operations available to AI agents without writing a separate MCP server by hand. +## Upgrading from a 1.x SDK build + +This version builds on `ModelContextProtocol` **2.x**. Five things change for an application already +using `Repl.Mcp`; the repository's +[MCP reference](https://github.com/yllibed/repl/blob/main/docs/mcp-reference.md#upgrading-from-the-1x-sdk) +carries the full list. + +- **The SDK moves to 2.x.** It is a transitively public dependency, so a consumer referencing it + directly moves with this package. The 1.x and 2.x assemblies cannot coexist. +- **`IMcpFeedback.SendMessageAsync` takes `McpMessageLevel`** instead of the SDK's deprecated + `LoggingLevel`. Same members, same values — the swap is mechanical. +- **Tool results can carry extra content blocks.** A message the client could not receive as a + notification is appended after the command's payload. The payload stays the first block and + `StructuredContent` is untouched, but a test asserting exactly one block will fail. +- **`.LongRunning()` no longer advertises task support on the protocol surface**, because SDK 2.x + removed the per-tool execution augmentation. The annotation still reaches help and documentation. +- **Module presence no longer varies with the client on `2026-07-28`**, which requires the advertised + set not to vary per connection. Discovery there runs every presence predicate against fixed + answers — `IsSupported`, `IsLoggingSupported` and `IsProgressSupported` are true, `HasSoftRoots` is + false, `Current` and `GetAsync()` are empty — and whatever the predicate returns is what every + client is offered. Read it off the result, not off the member: one that comes out true is + advertised **to every client** and stays callable by every client, so that command must now return + a clear error instead of relying on being absent; one that comes out false, including a negated capability gate such as + `!roots.IsSupported`, is advertised **to none** and disappears with no error, so map it + unconditionally. Earlier revisions are unchanged. + ## Install ```bash @@ -30,7 +56,13 @@ myapp # still a CLI / interactive REPL `IReplInteractionChannel` user feedback maps to MCP-native transports: - progress -> progress notifications -- notice / warning / problem feedback -> MCP message notifications +- notice / warning / problem feedback -> MCP message notifications, or the result itself + +On `2026-07-28` a request that declared no log level must receive no message notifications, so that +feedback is appended to the tool or prompt result instead — and to the surfaced error when the call +fails. A resource read is the exception: its body has to match the advertised MIME type, so a read +that succeeds keeps only that body and the feedback it reported is dropped on purpose, while a read +that fails carries it in the surfaced error. Everywhere else it survives. Keep operator logging on `ILogger`; do not rely on user-facing interaction as a logging sink. @@ -76,7 +108,7 @@ Clients with MCP Apps support render the generated `ui://` resource. Other MCP c | `.Destructive()` | `destructiveHint` — ask for confirmation | | `.Idempotent()` | retry-safe hint | | `.OpenWorld()` | external-system hint | -| `.LongRunning()` | long-running-operation hint | +| `.LongRunning()` | help and documentation hint only — nothing on the protocol surface | | `.AsResource()` | MCP resource with `repl://` URI | | `.AsMcpAppResource()` | MCP Apps HTML resource with `ui://` URI | | `.WithMcpAppBorder()` | MCP Apps border/background preference | @@ -104,7 +136,7 @@ app.Map("debug reset", handler) .AutomationHidden(); ``` -Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for operations that should use call-now / poll-later patterns, and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. +Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for slow operations (a documentation hint today — protocol-level MCP task advertisement returns once Repl integrates the SDK Tasks extension), and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. Prefer returning JSON-friendly objects instead of writing prose-only output. Structured results are easier for agents to inspect, retry, test, and summarize. diff --git a/src/Repl.Mcp/ReplMcpServerPrompt.cs b/src/Repl.Mcp/ReplMcpServerPrompt.cs index 8568f010..fbfa224d 100644 --- a/src/Repl.Mcp/ReplMcpServerPrompt.cs +++ b/src/Repl.Mcp/ReplMcpServerPrompt.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; @@ -64,6 +64,8 @@ public override async ValueTask GetAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); + // Prompt arguments are already JsonElement — pass through directly. var jsonArgs = request.Params.Arguments is { } args ? new Dictionary(args, StringComparer.Ordinal) @@ -73,29 +75,38 @@ public override async ValueTask GetAsync( _protocolPrompt.Name, jsonArgs, request.Server, progressToken: null, cancellationToken) .ConfigureAwait(false); + // Materialised once, and before the error branch: the adapter appends any message the client + // could not receive as a notification after the payload, and a prompt that fails must not drop + // them either — a failure is exactly when the notices leading up to it are worth having. + var blocks = result.Content?.OfType().ToArray() ?? []; + // Surface errors as MCP exceptions so clients can distinguish failures. if (result.IsError == true) { - var errorText = result.Content?.OfType().FirstOrDefault()?.Text - ?? "Prompt execution failed."; - throw new McpException(McpJsonStringOutput.UnwrapJsonStringLiteral(errorText)); + throw new McpException(McpToolAdapter.BuildErrorMessage(blocks, "Prompt execution failed.")); } - var outputText = result.Content?.OfType().FirstOrDefault()?.Text; + var outputText = blocks.Length > 0 ? blocks[0].Text : null; var text = outputText is null ? _command.Description ?? _protocolPrompt.Name : McpJsonStringOutput.UnwrapJsonStringLiteral(outputText); - return new GetPromptResult + var messages = new List(Math.Max(blocks.Length, 1)) { - Messages = - [ - new PromptMessage - { - Role = Role.User, - Content = new TextContentBlock { Text = text }, - }, - ], + new() + { + Role = Role.User, + Content = new TextContentBlock { Text = text }, + }, }; + + // Buffered feedback rides after the payload, as it does on the tool path. Keeping only the + // first block made prompts/get the one caller that silently discarded it. + for (var i = 1; i < blocks.Length; i++) + { + messages.Add(new PromptMessage { Role = Role.User, Content = blocks[i] }); + } + + return new GetPromptResult { Messages = messages }; } } diff --git a/src/Repl.Mcp/ReplMcpServerResource.cs b/src/Repl.Mcp/ReplMcpServerResource.cs index bd979708..7c8d8eeb 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -68,6 +68,7 @@ public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = ExtractArguments(request.Params.Uri); var result = await _adapter.InvokeResourceAsync( @@ -82,7 +83,7 @@ public override async ValueTask ReadAsync( { throw new McpException(result.Text); } - return new ReadResourceResult + return McpCacheHints.MarkPrivateToThisClient(request, new ReadResourceResult { Contents = [ @@ -93,7 +94,7 @@ public override async ValueTask ReadAsync( Text = result.Text, }, ], - }; + }); } private Dictionary ExtractArguments(string uri) diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index cb4b1c0f..b3aa7b28 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; @@ -14,10 +14,11 @@ internal sealed class ReplMcpServerTool : McpServerTool private readonly McpToolAdapter _adapter; private readonly Tool _protocolTool; - // MCP Tasks are experimental in the SDK (MCPEXP001) but part of the MCP spec. - // LongRunning commands advertise optional task support so agents can use - // the call-now/poll-later pattern instead of blocking on slow operations. -#pragma warning disable MCPEXP001 + // SDK 2.0 extracted MCP Tasks into ModelContextProtocol.Extensions.Tasks (store, task + // results, client polling) and dropped the per-tool Tool.Execution / ToolTaskSupport + // augmentation from the protocol surface. Repl keeps .LongRunning() in its own model + // (help/docs) and deliberately does not advertise task support until Repl integrates + // the Tasks extension end-to-end (tasks/get|update|cancel) — tracked in issue #72. public ReplMcpServerTool( ReplDocCommand command, string toolName, @@ -31,15 +32,11 @@ public ReplMcpServerTool( InputSchema = McpSchemaGenerator.BuildInputSchema(command), OutputSchema = McpSchemaGenerator.BuildOutputSchema(command), Annotations = McpSchemaGenerator.MapAnnotations(command.Annotations), - Execution = command.Annotations?.LongRunning == true - ? new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - : null, Meta = TryGetAppOptions(command, out var appOptions) ? McpAppMetadata.BuildToolMeta(appOptions) : null, }; } -#pragma warning restore MCPEXP001 /// public override Tool ProtocolTool => _protocolTool; @@ -52,6 +49,7 @@ public override async ValueTask InvokeAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = request.Params.Arguments ?? new Dictionary(StringComparer.Ordinal); var progressToken = request.Params.ProgressToken; diff --git a/src/Repl.Mcp/ReplMcpServerUiResource.cs b/src/Repl.Mcp/ReplMcpServerUiResource.cs index 5c49059a..39a7659b 100644 --- a/src/Repl.Mcp/ReplMcpServerUiResource.cs +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -57,6 +57,7 @@ public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = ExtractArguments(request.Params.Uri); var result = await _adapter.InvokeAsync( @@ -68,15 +69,19 @@ public override async ValueTask ReadAsync( allowStaticResults: false) .ConfigureAwait(false); + // Materialised once, before the error branch: a failed read has no body to carry the feedback + // the adapter appended, so the surfaced error is the only place left for it. + var blocks = result.Content?.OfType().ToArray() ?? []; + if (result.IsError == true) { - var errorText = result.Content?.OfType().FirstOrDefault()?.Text - ?? "UI resource read failed."; - throw new McpException(errorText); + throw new McpException(McpToolAdapter.BuildErrorMessage(blocks, "UI resource read failed.")); } - var text = result.Content?.OfType().FirstOrDefault()?.Text ?? ""; - return new ReadResourceResult + // Only the payload on success: the body has to match the advertised MIME type, so any trailing + // feedback block is deliberately dropped here. + var text = blocks.Length > 0 ? blocks[0].Text : ""; + return McpCacheHints.MarkPrivateToThisClient(request, new ReadResourceResult { Contents = [ @@ -88,7 +93,7 @@ public override async ValueTask ReadAsync( Meta = McpAppMetadata.BuildResourceMeta(_options.ResourceOptions), }, ], - }; + }); } private Dictionary ExtractArguments(string uri) diff --git a/src/Repl.McpTests/Given_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs index b87ef8d3..c1440bcb 100644 --- a/src/Repl.McpTests/Given_McpAgentCapabilities.cs +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -4,6 +4,11 @@ using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpApps.cs b/src/Repl.McpTests/Given_McpApps.cs index 71e51379..7a5970f2 100644 --- a/src/Repl.McpTests/Given_McpApps.cs +++ b/src/Repl.McpTests/Given_McpApps.cs @@ -1,6 +1,9 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using Repl.Interaction; using Repl.Mcp; namespace Repl.McpTests; @@ -103,6 +106,333 @@ public async Task When_UiResourceRead_Then_ReturnsHtmlWithMcpAppMimeType() .Should().ContainSingle("https://cdn.example.com"); } + [TestMethod] + [Description("Regression guard: a UI resource read that fails must carry the feedback its command emitted. On success the trailing blocks are dropped on purpose — the body has to match the advertised MIME type — but a failure has no body at all, so the surfaced error is the only place left for them, and reading just the first content block discarded them.")] + public async Task When_AFailingUiResourceReadEmitsFeedback_Then_ItRidesInTheError() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map( + "dashboard", + static async Task (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteNoticeAsync( + text: "render-notice", + cancellationToken: cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("render-failed"); + }).AsMcpAppResource("ui://contacts/dashboard"); + }).ConfigureAwait(false); + + var act = async () => + await fixture.Client.ReadResourceAsync("ui://contacts/dashboard").ConfigureAwait(false); + + (await act.Should().ThrowAsync().ConfigureAwait(false)) + .Which.Message.Should().Contain( + "render-notice", + because: "a failed read has nowhere else to carry what the command reported"); + } + + [TestMethod] + [Description("Regression guard: a raw UI resource handler reading IMcpClientRoots.Current must see the connection\u0027s roots on the first read. This resource is the one prebuilt primitive that does not run through McpToolAdapter, so it does not inherit the execution-boundary priming the command-backed paths get and needs its own.")] + public async Task When_ARawUiResourceReadsCurrentRoots_Then_TheFirstReadSeesThem() + { + // Roots are deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported by + // Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = static (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "file:///C:/workspace", Name = "workspace" }], + }), + }, + }; +#pragma warning restore MCP9005 + + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/roots", + (IMcpClientRoots roots) => + "" + + string.Join(',', roots.Current.Select(static root => root.Uri.ToString())) + + ""), + clientOptions: clientOptions).ConfigureAwait(false); + + var result = await fixture.Client.ReadResourceAsync("ui://probe/roots").ConfigureAwait(false); + + result.Contents.OfType().Single().Text.Should().Contain( + "file:///C:/workspace", + because: "Current is documented as the connection\u0027s effective roots on every execution path"); + } + + [TestMethod] + [Description("Regression guard: a raw UI resource handler that reports feedback and then throws must not lose it. On 2026-07-28 a request that declared no log level receives no message notifications, so the buffer is the only carrier — and this primitive opened none, because it bypasses the adapter that opens one for every other path.")] + public async Task When_ARawUiResourceReportsThenFails_Then_TheFeedbackRidesInTheError() + { + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/fails", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "render-warning", + cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("render-failed"); + })).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var act = async () => await fixture.Client.ReadResourceAsync("ui://probe/fails").ConfigureAwait(false); + + (await act.Should().ThrowAsync().ConfigureAwait(false)) + .Which.Message.Should().Contain( + "render-warning", + because: "a failed read has no body, so the error is the only place the warning can ride"); + } + + [TestMethod] + [Description("Pins the other half of the feedback rule: a UI resource read that SUCCEEDS keeps only its body. Every other path appends an undeliverable message to the result, and a resource read cannot \u2014 its result is a typed body whose MIME type was advertised, so appending prose would corrupt what the client parses. Only the failing read, which has no body left to protect, carries the feedback in its error.")] + public async Task When_ARawUiResourceReportsAndSucceeds_Then_TheBodyCarriesNoFeedback() + { + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/reports", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "render-warning", + cancellationToken).ConfigureAwait(false); + return "ok"; + })).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var read = await fixture.Client.ReadResourceAsync("ui://probe/reports").ConfigureAwait(false); + + var body = read.Contents.OfType().Single(); + body.MimeType.Should().Be(McpAppValidation.ResourceMimeType); + body.Text.Should().Be( + "ok", + because: "the advertised MIME type is a promise about the body, and feedback is not part of it"); + } + + [TestMethod] + [Description("Regression guard: a UI resource handler that runs its own budget, reports feedback and then gives up must not lose that feedback. Cancellation is told apart by who asked for it, not by the exception type \u2014 the caller abandoning the request is the only case with nobody left to read the answer. A handler timing out internally is a failure like any other, and its diagnostic is the only thing explaining why the read produced nothing.")] + public async Task When_ARawUiResourceReportsThenCancelsItself_Then_TheFeedbackStillRides() + { + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/self-cancels", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "render-warning", + cancellationToken).ConfigureAwait(false); + + // The handler's own budget, not the caller's: the request token stays live throughout. + using var ownBudget = new CancellationTokenSource(); + await ownBudget.CancelAsync().ConfigureAwait(false); + ownBudget.Token.ThrowIfCancellationRequested(); + return "unreachable"; + })).ConfigureAwait(false); + + var act = async () => await fixture.Client.ReadResourceAsync("ui://probe/self-cancels").ConfigureAwait(false); + + (await act.Should().ThrowAsync().ConfigureAwait(false)) + .Which.Message.Should().Contain( + "render-warning", + because: "the caller never withdrew, so there is still someone to read what the handler said"); + } + + [TestMethod] + [Description("Regression guard: a command-backed App resource that fails must carry the app-authored feedback and withhold the handler's own exception text — the same split the raw UiResource path applies. This path reaches it differently: it surfaces the tool result's blocks rather than wrapping the exception, so the two could drift apart without either being obviously wrong.")] + public async Task When_ACommandBackedAppFails_Then_FeedbackRidesAndTheDetailIsWithheld() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + app.Map("dash", async (IMcpFeedback feedback, CancellationToken ct) => + { + await feedback.SendMessageAsync(McpMessageLevel.Warning, "render-notice", ct).ConfigureAwait(false); + throw new InvalidOperationException("secret-internal-detail"); + }) + .ReadOnly() + .AsMcpAppResource("ui://probe/dash")).ConfigureAwait(false); + + var act = async () => await fixture.Client.ReadResourceAsync("ui://probe/dash").ConfigureAwait(false); + + var message = (await act.Should().ThrowAsync().ConfigureAwait(false)).Which.Message; + + message.Should().Contain( + "render-notice", + because: "the app authored that for the client and a failed read has no body to carry it"); + message.Should().NotContain( + "secret-internal-detail", + because: "the handler threw it, so it is the framework speaking about code, not the app speaking to a caller"); + } + + [TestMethod] + [Description("Regression guard: an McpException a handler raises on purpose carries a message written for the client, and buffering feedback beforehand must not replace it. Withholding applies to what the framework rendered from an exception nobody meant to surface \u2014 the SDK passes an McpException through verbatim precisely because raising one is a deliberate act.")] + public async Task When_ARawUiResourceReportsThenRaisesAnMcpException_Then_ItsMessageSurvives() + { + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/explains", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "render-warning", + cancellationToken).ConfigureAwait(false); + throw new McpException("Dashboard needs a workspace root to render."); + })).ConfigureAwait(false); + + var act = async () => await fixture.Client.ReadResourceAsync("ui://probe/explains").ConfigureAwait(false); + + var message = (await act.Should().ThrowAsync().ConfigureAwait(false)).Which.Message; + + message.Should().Contain( + "Dashboard needs a workspace root to render.", + because: "the handler raised that deliberately and the client is who it was written for"); + message.Should().Contain( + "render-warning", + because: "buffered feedback still rides along, it does not replace the failure"); + } + + [TestMethod] + [Description("Regression guard: a failed UI resource read must not hand the client the handler\u0027s own exception message. The SDK flattens any non-McpException to a generic string and passes an McpException\u0027s message through verbatim, so wrapping the failure in order to carry buffered feedback is precisely the act that would disclose an IOException\u0027s path or a binding failure\u0027s parameter type. Only the app-authored feedback travels.")] + public async Task When_ARawUiResourceReportsThenFails_Then_TheHandlersOwnMessageIsWithheld() + { + await using var fixture = await McpTestFixture.CreateAsync( + _ => { }, + options => options.UiResource( + "ui://probe/fails", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "render-warning", + cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("secret-internal-detail"); + })).ConfigureAwait(false); + + var act = async () => await fixture.Client.ReadResourceAsync("ui://probe/fails").ConfigureAwait(false); + + var message = (await act.Should().ThrowAsync().ConfigureAwait(false)).Which.Message; + + message.Should().Contain( + "render-warning", + because: "the app authored that message for the client and a failed read has no body to carry it"); + message.Should().NotContain( + "secret-internal-detail", + because: "the handler\u0027s own failure text is not the app speaking and must not reach the client"); + } + + [TestMethod] + [Description("Regression guard: the Apps extension must be advertised when the only App-bearing registration is shadowed by a later one for the same template. Route resolution keeps the last registration per template, so a probe that resolves before answering sees only the shadowing route \u2014 while every connection whose gate excludes the shadowing module is served the App underneath it, and would be handed App metadata with nothing to interpret it.")] + public async Task When_AnAppRegistrationIsShadowed_Then_TheAppsExtensionIsStillAdvertised() + { + // Roots are deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported by + // Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.MapModule(new ShadowedAppModule()); + app.MapModule(new ShadowingPlainModule(), (IMcpClientRoots roots) => roots.IsSupported); + }).ConfigureAwait(false); +#pragma warning restore MCP9005 + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().Contain( + tool => string.Equals(tool.Name, "dashboard", StringComparison.Ordinal), + because: "this client declares no roots, so the shadowing module is absent and the App is served"); + +#pragma warning disable MCPEXP001 + fixture.Client.ServerCapabilities?.Extensions.Should().NotBeNull() + .And.ContainKey( + McpAppMetadata.ExtensionName, + because: "the served catalog contains an App, whatever a fully-resolved probe would have kept"); +#pragma warning restore MCPEXP001 + } + + private sealed class ShadowedAppModule : IReplModule + { + public void Map(IReplMap app) => + app.Map("dashboard", () => "app") + .ReadOnly() + .AsMcpAppResource("ui://shadowed/dashboard"); + } + + /// Registered after and claiming the same template. + private sealed class ShadowingPlainModule : IReplModule + { + public void Map(IReplMap app) => app.Map("dashboard", () => "plain").ReadOnly(); + } + + [TestMethod] + [Description("Regression guard: the Apps extension must be advertised for any catalog the handler can build, including one whose App sits behind a gate on the roots DATA. A real initialize-era snapshot resolves the client\u0027s roots before evaluating presence predicates, so that App is in the catalog — while a requestless probe evaluates the gate as false and would advertise nothing, leaving an Apps-aware client holding App metadata it cannot interpret.")] + public async Task When_AnAppIsGatedOnTheRootsData_Then_TheAppsExtensionIsStillAdvertised() + { + // Roots are deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported by + // Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + var clientOptions = new McpClientOptions + { + ProtocolVersion = McpProtocolRevisions.LastWithSessions, + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = static (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "file:///C:/workspace", Name = "workspace" }], + }), + }, + }; +#pragma warning restore MCP9005 + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new DataGatedAppModule(), (IMcpClientRoots roots) => roots.Current.Count > 0), + configureOptions: null, + clientOptions: clientOptions).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().Contain( + tool => string.Equals(tool.Name, "dashboard", StringComparison.Ordinal), + because: "the real legacy snapshot resolves roots first, so the gate matches and the App is served"); + +#pragma warning disable MCPEXP001 + fixture.Client.ServerCapabilities?.Extensions.Should().NotBeNull() + .And.ContainKey( + McpAppMetadata.ExtensionName, + because: "a client served App metadata must be told the extension it needs to read it"); +#pragma warning restore MCPEXP001 + } + + private sealed class DataGatedAppModule : IReplModule + { + public void Map(IReplMap app) => + app.Map("dashboard", () => "ok") + .ReadOnly() + .AsMcpAppResource("ui://gated/dashboard"); + } + [TestMethod] [Description("Apps metadata does not change regular tool fallback output.")] public async Task When_AppToolCalled_Then_TextFallbackStillWorks() diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index 6bfadfec..c3b2d5e5 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -1,10 +1,600 @@ +using System.IO.Pipelines; +using System.Text; +using System.Text.Json.Nodes; +using ModelContextProtocol; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; namespace Repl.McpTests; [TestClass] public sealed class Given_McpConcurrentSessions { + [TestMethod] + [Description("Pins the protocol revision every other guarantee in this file is written against: two sessions sharing one handler must both negotiate 2026-07-28. Without this, a silent fallback to the 2025-11-25 initialize handshake would make the per-session capability and catalog assertions below describe a revision they were never meant to characterise.")] + public async Task When_TwoSessionsShareOneHandler_Then_BothNegotiateTheModernRevision() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + sessionA.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + sessionB.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + } + + [TestMethod] + [Description("Guards capability binding against cross-session interference: with one handler serving two sessions (SDK 2.0 binds a destination server per request), a paused call from a sampling-capable client must still observe ITS OWN client's capabilities after a request from a sampling-less client has been served — the capability services must bind to the flowing request, not to a shared last-attached server.")] + public async Task When_TwoClientsWithDifferentCapabilitiesShareHandler_Then_CapabilityBindingIsPerRequest() + { + using var entered = new SemaphoreSlim(0, 1); + using var gate = new SemaphoreSlim(0, 1); + + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("probe", async (IMcpSampling sampling) => + { + var before = sampling.IsSupported; + entered.Release(); + await gate.WaitAsync().ConfigureAwait(false); + var after = sampling.IsSupported; + return $"{before}|{after}"; + }); + app.Map("poke", () => "ok"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + // Session A enters "probe" (sampling supported) and pauses on the gate; session B is + // then served in full; A resumes and must STILL see its own sampling capability. + var probeTask = sessionA.Client.CallToolAsync( + "probe", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token); + (await entered.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false)).Should().BeTrue(); + + await sessionB.Client.CallToolAsync( + "poke", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token) + .ConfigureAwait(false); + + gate.Release(); + var probeResult = await probeTask.ConfigureAwait(false); + + probeResult.Content.OfType().First().Text.Should().Contain("True|True"); + } + + [TestMethod] + [Description("Guards root isolation across sessions sharing one handler: the hard-roots cache must be keyed by session, otherwise the second root-capable client silently receives the FIRST client's workspace roots — a cross-session data exposure — instead of its own roots/list round-trip.")] + public async Task When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("roots", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(root => root.Uri.ToString()))); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + var resultA = await sessionA.Client.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + var resultB = await sessionB.Client.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + resultA.Content.OfType().First().Text.Should().Contain("file:///ga"); + var textB = resultB.Content.OfType().First().Text; + textB.Should().Contain("file:///bu"); + textB.Should().NotContain("file:///ga"); + } + + [TestMethod] + [Description("Guards routing-notification lifetime across sessions: when the first-attached session closes, the surviving session must still receive tools/list_changed after a routing invalidation — session attachment must be reference-counted, not first-wins with a handler-wide unsubscribe on first close. The surviving session subscribes through subscriptions/listen, which is how a 2026-07-28 client asks for the notification at all.")] + public async Task When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = sessionB.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }); + await using var scopeRegistration = registration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = sessionB.Client.SendRequestAsync( + RequestMethods.SubscriptionsListen, + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true }, + }, + cancellationToken: listenCts.Token) + .AsTask(); + + // Both sessions are live; close the FIRST one, then invalidate routing. Disposing the + // session awaits its RunAsync, so a teardown fault surfaces here instead of being swallowed. + await sessionA.DisposeAsync().ConfigureAwait(false); + + app.Map("late", () => "l"); + app.Core.InvalidateRouting(); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await listenCts.CancelAsync().ConfigureAwait(false); + try + { + // Started above and awaited only after cancellation; MSTest has no sync context. +#pragma warning disable VSTHRD003 + await listenTask.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected: the listen stream ends on cancellation. + } + } + + [TestMethod] + [Description("Guards the other half of the 2026-07-28 rule: the advertised set MUST NOT change as a side effect of another request on the connection. A tool that writes session state and invalidates routing is exactly that side effect \u2014 it needs no second connection to be observable \u2014 so discovery must answer session state with a constant, the same way it already answers the capability services.")] + public async Task When_AModernToolMutatesSessionState_Then_TheAdvertisedSetDoesNotChange() + { + var app = BuildSessionGatedApp(); + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var before = await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + await session.Client.CallToolAsync( + toolName: "signin", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + var after = await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + after.Select(static tool => tool.Name).Should().BeEquivalentTo( + before.Select(static tool => tool.Name), + because: "a tools/call must not change what the next tools/list advertises on this revision"); + after.Should().NotContain( + tool => string.Equals(tool.Name, "secret", StringComparison.Ordinal), + because: "discovery reads a constant session state, so a gate on it decides once for everyone"); + } + + [TestMethod] + [Description("The same shape on the initialize era, where the set is allowed to vary: a tool that writes session state and invalidates routing must still change what the next tools/list advertises. The freeze belongs to 2026-07-28 alone, and this guard is what stops it leaking into a revision whose whole dynamic-tools story depends on the graph moving.")] + public async Task When_ALegacyToolMutatesSessionState_Then_TheAdvertisedSetChanges() + { + var app = BuildSessionGatedApp(); + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + handler, + BuildLegacyRootsClientOptions("file:///ga"), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().NotContain(tool => string.Equals(tool.Name, "secret", StringComparison.Ordinal)); + + await session.Client.CallToolAsync( + toolName: "signin", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain( + tool => string.Equals(tool.Name, "secret", StringComparison.Ordinal), + because: "this revision has sessions, so a graph that moves with them is the point"); + } + + [TestMethod] + [Description("Regression guard: what modern discovery advertises must be callable. Discovery answers the capability questions with constants, so a module gated on roots.IsSupported is offered to every client — and the documented contract is that calling it without the capability produces an actionable failure from the command itself. Execution resolved the graph again from the live services, so the route was simply absent and the caller got Unknown command instead, with nothing naming what was missing.")] + public async Task When_AModernClientCallsACapabilityGatedTool_Then_TheCommandRuns() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + var result = await session.Client.CallToolAsync( + toolName: "gated", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + text.Should().Contain( + "roots-only", + because: "the command was advertised to this client, so calling it must reach the handler"); + text.Should().NotContain( + "unknown_command", + because: "advertising a tool and then denying it exists tells the caller nothing it can act on"); + } + + [TestMethod] + [Description("The counterpart: a command that discovery did NOT advertise must stay unreachable. Making the advertised set executable must not quietly open commands the graph hides — here the gate reads the roots DATA, which discovery answers as empty, so the command is offered to nobody and must be callable by nobody either.")] + public async Task When_AModernClientCallsAnUnadvertisedGatedTool_Then_ItIsStillNotFound() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.Current.Count > 0); + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + handler, + BuildRootsClientOptions("file:///ga"), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().NotContain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + var result = await session.Client.CallToolAsync( + toolName: "gated", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.IsError.Should().BeTrue( + because: "a command the catalog never offered must not become reachable by name"); + } + + [TestMethod] + [Description("Regression guard: on 2026-07-28 a transient projection failure must not leave two connections serving different catalogs. Availability is worth preserving on both revisions, but a per-session fallback buys it with exactly the variance this revision forbids \u2014 a connection that had not yet seen a routing change would keep its older set while another served the newer one. Modern connections fall back to one shared last-known-good catalog instead, so they move together or not at all.")] + public async Task When_AModernProjectionFailsTransiently_Then_EverySessionFallsBackTogether() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + var breakProjection = false; + var handler = CreateHandlerWithAppServices( + app, + options => options.CommandFilter = _ => breakProjection + ? throw new InvalidOperationException("projection-failure") + : true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var older = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var olderScope = older.ConfigureAwait(false); + + // This connection reads the catalog before the change, and deliberately never reads it again + // until the failure: it is the one that would otherwise be left behind. + (await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().NotContain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); + + app.Map("added", () => "new"); + app.Core.InvalidateRouting(); + + var newer = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var newerScope = newer.ConfigureAwait(false); + (await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); + + breakProjection = true; + app.Core.InvalidateRouting(); + + var olderTools = await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var newerTools = await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + olderTools.Select(static tool => tool.Name).Should().BeEquivalentTo( + newerTools.Select(static tool => tool.Name), + because: "the set MUST NOT vary per connection, and a failure is not an exception to that"); + } + + /// An app whose module appears only once a command has written the session state. + private static ReplApp BuildSessionGatedApp() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.Map("signin", (IReplSessionState state, ICoreReplApp core) => + { + state.Set(key: SignedInKey, value: true); + core.InvalidateRouting(); + return "ok"; + }); + app.MapModule(new SessionGatedModule(), (IReplSessionState state) => state.Get(SignedInKey)); + + return app; + } + + private const string SignedInKey = "auth.signed_in"; + + /// + /// A handler wired to the app's own container, which is what mcp serve does. The default + /// helper passes an empty provider, so a command injecting a framework session service cannot bind. + /// + private static McpServerHandler CreateHandlerWithAppServices( + ReplApp app, + Action? configure = null) + { + var options = new ReplMcpServerOptions { TransportFactory = McpTestFixture.PipeTransportFactory }; + configure?.Invoke(options); + return new McpServerHandler(app.Core, options, app.Services); + } + + private sealed class SessionGatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("secret", () => "classified").ReadOnly(); + } + + [TestMethod] + [Description("Guards the 2026-07-28 rule that the advertised tool set MUST NOT vary per-connection: two sessions on one handler, one declaring roots and one not, must receive the SAME set. Discovery answers every per-connection question with a constant on that revision, so a module gated on IMcpClientRoots.IsSupported is advertised to both and fails with an actionable error if called where it cannot work.")] + public async Task When_ModernSessionsShareAGatedGraph_Then_TheAdvertisedSetIsInvariant() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var withRoots = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeWithRoots = withRoots.ConfigureAwait(false); + var withoutRoots = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeWithoutRoots = withoutRoots.ConfigureAwait(false); + + withRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + withoutRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var toolsWithRoots = await withRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await withoutRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + toolsWithoutRoots.Select(static tool => tool.Name).Should().BeEquivalentTo( + toolsWithRoots.Select(static tool => tool.Name), + because: "the set MUST NOT vary per-connection on this revision"); + toolsWithRoots.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + toolsWithoutRoots.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + } + + [TestMethod] + [Description("Regression guard: listing tools on a modern revision must not send the client a roots/list. Discovery there consults no capability service, so pre-resolving roots before the build would reach the client as a side effect of tools/list for a result nothing reads — the interaction the invariance rule exists to prevent, and a round-trip on every rebuild.")] + public async Task When_AModernSessionListsTools_Then_NoRootsRoundTripIsMade() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + var roundTrips = 0; + + var session = await StartSessionAsync( + handler, + BuildCountingRootsClientOptions(() => Interlocked.Increment(ref roundTrips)), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + Volatile.Read(ref roundTrips).Should().Be( + 0, + because: "nothing in a modern discovery pass reads the client's roots"); + } + + [TestMethod] + [Description("Regression guard for the whole roots surface, not just its booleans: a predicate reading roots.Current — the workspace list itself — must not make the advertised set vary either. The discovery view reaches no live service at all, so every member answers a constant.")] + public async Task When_AModernPredicateReadsTheRootsData_Then_TheAdvertisedSetIsStillInvariant() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.Current.Count > 0); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var withRoots = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeWithRoots = withRoots.ConfigureAwait(false); + var withoutRoots = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeWithoutRoots = withoutRoots.ConfigureAwait(false); + + withRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + withoutRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var toolsWithRoots = await withRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await withoutRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + toolsWithoutRoots.Select(static tool => tool.Name).Should().BeEquivalentTo( + toolsWithRoots.Select(static tool => tool.Name), + because: "reading the roots data must not vary the set any more than reading IsSupported does"); + + // Equality alone is satisfied by two EMPTY sets, so state where a data-gated module actually + // lands: discovery answers an empty root list, so the predicate is false for everyone. + toolsWithRoots.Should().Contain(tool => string.Equals(tool.Name, "always", StringComparison.Ordinal)); + toolsWithRoots.Should().NotContain( + tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal), + because: "discovery answers an empty root list, so a predicate reading it is false for every " + + "client — the module is advertised to none, not to all"); + } + + [TestMethod] + [Description("Regression guard pinning the whole discovery overlay, not one key of it: a client declaring NO capabilities must still be advertised every module gated on a capability being supported — roots, sampling, elicitation and feedback alike. Dropping any single service from the modern discovery overlay silently removes that module for everyone, and a test that only covers roots would stay green through it.")] + public async Task When_AModernClientDeclaresNoCapabilities_Then_EveryCapabilityGatedModuleIsStillAdvertised() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new GateProbeModule("gate_roots"), (IMcpClientRoots roots) => roots.IsSupported); + app.MapModule(new GateProbeModule("gate_sampling"), (IMcpSampling sampling) => sampling.IsSupported); + app.MapModule(new GateProbeModule("gate_elicitation"), (IMcpElicitation elicitation) => elicitation.IsSupported); + app.MapModule(new GateProbeModule("gate_feedback"), (IMcpFeedback feedback) => feedback.IsLoggingSupported); + app.MapModule(new GateProbeModule("gate_progress"), (IMcpFeedback feedback) => feedback.IsProgressSupported); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var tools = (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Select(static tool => tool.Name) + .ToArray(); + + tools.Should().Contain("always"); + foreach (var gated in new[] + { + "gate_roots", "gate_sampling", "gate_elicitation", "gate_feedback", "gate_progress", + }) + { + tools.Should().Contain( + gated, + because: "this client declares nothing, so {0} proves its capability service is neutralised in discovery", + gated); + } + } + + [TestMethod] + [Description("Guards that the invariance above is scoped to the revision that requires it: the initialize-era revisions establish a session and state no such rule, so a capability-gated graph is still computed per session there. Without this, making the modern set invariant could silently take the feature away from every legacy client too.")] + public async Task When_LegacySessionsShareAGatedGraph_Then_EachSeesItsOwnTools() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var withRoots = await StartSessionAsync(handler, BuildLegacyRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeWithRoots = withRoots.ConfigureAwait(false); + var withoutRoots = await StartSessionAsync( + handler, + new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }, + cts.Token).ConfigureAwait(false); + await using var scopeWithoutRoots = withoutRoots.ConfigureAwait(false); + + withRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + withoutRoots.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var toolsWithRoots = await withRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await withoutRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + toolsWithRoots.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + toolsWithoutRoots.Should().Contain(tool => string.Equals(tool.Name, "always", StringComparison.Ordinal)); + toolsWithoutRoots.Should().NotContain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + } + + [TestMethod] + [Description("Pins Repl's own cache tagging on 2026-07-28 rather than whatever the SDK defaults to: SEP-2549 reads an absent cacheScope as Public, which would let a shared gateway hand Repl's list to the next caller. The tag is a conservative default, NOT what makes a varying list legal — that rule is satisfied by the set being invariant, guarded separately. Every list the handler serves is tagged private and immediately stale.")] + public async Task When_ModernClientListsTools_Then_ListResultIsTaggedPrivateAndStale() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var session = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + var result = await session.Client.SendRequestAsync( + RequestMethods.ToolsList, + new ListToolsRequestParams(), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.Tools.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + result.CacheScope.Should().Be(CacheScope.Private); + result.TimeToLive.Should().Be(TimeSpan.Zero); + } + + [TestMethod] + [Description("Guards the compatibility-shim intro across sessions: DiscoverAndCallShim serves the discover_tools/call_tool intro on each session's FIRST tools/list — a handler-global flag would give the intro only to whichever session listed first, leaving later sessions without the documented bootstrap. Pinned to an initialize-era revision, which is where a catalog may change across requests on one connection.")] + public async Task When_ShimEnabledAndTwoLegacySessionsList_Then_EachSessionGetsTheIntro() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app, DynamicToolCompatibilityMode.DiscoverAndCallShim); + using var cts = new CancellationTokenSource(); + + var legacy = new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }; + var sessionA = await StartSessionAsync(handler, legacy, cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, legacy, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + sessionA.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var firstListA = await sessionA.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var firstListB = await sessionB.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + firstListA.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + firstListB.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + } + + [TestMethod] + [Description("Regression guard: the compatibility bootstrap must not run on 2026-07-28. Serving discover_tools/call_tool on the first tools/list and the real catalog on the next is a connection-local change caused by another request on that connection — the second half of the MUST NOT, and observable with a single connection. A modern client gets the real catalog immediately, and twice in a row it gets the same one.")] + public async Task When_ShimEnabledAndAModernSessionLists_Then_TheCatalogIsTheSameEveryTime() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app, DynamicToolCompatibilityMode.DiscoverAndCallShim); + using var cts = new CancellationTokenSource(); + + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var firstList = await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var secondList = await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + firstList.Select(static tool => tool.Name).Should().BeEquivalentTo( + ["alpha"], + because: "the bootstrap pair would be a catalog this connection only sees once"); + secondList.Select(static tool => tool.Name).Should().BeEquivalentTo( + firstList.Select(static tool => tool.Name), + because: "the set must not change as a side effect of the previous request"); + } + [TestMethod] [Description("Two independent MCP sessions can run concurrently without interference.")] public async Task When_TwoSessionsRunConcurrently_Then_EachSeesOwnTools() @@ -69,4 +659,748 @@ public async Task When_ToolsInvokedConcurrently_Then_OutputIsIsolated() } } } + + private static Task StartSessionAsync( + McpServerHandler handler, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) => + McpPipeSession.StartAsync(handler.RunAsync, clientOptions, cancellationToken); + + private static McpServerHandler CreateHandler( + ReplApp app, + DynamicToolCompatibilityMode compatibility = DynamicToolCompatibilityMode.Disabled) + { + var options = new ReplMcpServerOptions + { + DynamicToolCompatibility = compatibility, + TransportFactory = McpTestFixture.PipeTransportFactory, + }; + + return new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + } + + private sealed class RootsGatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("gated", () => "roots-only"); + } + + // Roots is deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported by + // Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + [TestMethod] + [Description("Regression guard: one connection really can be served both eras, so the snapshot cache key has to carry the era. The SDK accepts a modern per-request _meta call and then an initialize handshake on the same pipe, and the two eras see different command graphs — a capability gate reads as supported during modern discovery and against the real client on legacy. Without the era in the key the second request is served the first one\u0027s catalog.")] + public async Task When_OneConnectionIsServedBothEras_Then_EachGetsItsOwnCatalog() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandler(app); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var io = new McpRawIo(clientToServer, serverToClient); + var serverTask = handler.RunAsync( + new McpTestFixture.PipeIoContext( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()), + cts.Token); + + try + { + // A modern request: the era travels in _meta, there is no handshake. + var modern = await io.CallAsync( + id: 1, + method: "tools/list", + meta: new JsonObject + { + ["io.modelcontextprotocol/protocolVersion"] = McpProtocolRevisions.Sessionless, + ["io.modelcontextprotocol/clientCapabilities"] = new JsonObject(), + }, + cancellationToken: cts.Token).ConfigureAwait(false); + + ToolNames(modern).Should().Contain( + "gated", + because: "modern discovery answers IsSupported with a constant, so the gate matches for everyone"); + + // The same connection then opens a legacy session, which the SDK accepts. + await io.InitializeLegacyAsync(id: 2, cts.Token).ConfigureAwait(false); + + var legacy = await io.CallAsync( + id: 3, + method: "tools/list", + meta: null, + cancellationToken: cts.Token).ConfigureAwait(false); + + ToolNames(legacy).Should().Contain("always"); + ToolNames(legacy).Should().NotContain( + "gated", + because: "this legacy client declares no roots, so it must not be served the catalog the " + + "modern request seeded on the same connection"); + } + finally + { + await StopRawServerAsync(cts, io, serverTask).ConfigureAwait(false); + } + } + + private static async Task StopRawServerAsync(CancellationTokenSource cts, McpRawIo io, Task serverTask) + { + await cts.CancelAsync().ConfigureAwait(false); + await io.DisposeAsync().ConfigureAwait(false); + try + { + await serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: RunAsync ends on cancellation. + } + } + + private static IReadOnlyList ToolNames(JsonObject response) => + [ + .. (response["result"]?["tools"]?.AsArray() ?? []) + .Select(static tool => tool?["name"]?.GetValue() ?? ""), + ]; + + /// + /// A raw newline-delimited JSON-RPC peer. cannot express this test: it + /// speaks one era for the life of a connection, and the point here is to send both down one pipe. + /// + private sealed class McpRawIo(Pipe clientToServer, Pipe serverToClient) : IAsyncDisposable + { + private readonly StreamReader _reader = new(serverToClient.Reader.AsStream(), Encoding.UTF8); + + public async Task CallAsync( + int id, + string method, + JsonObject? meta, + CancellationToken cancellationToken, + JsonObject? parameters = null) + { + var parameterObject = parameters ?? []; + if (meta is not null) + { + parameterObject["_meta"] = meta; + } + + await WriteAsync( + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = parameterObject, + }, + cancellationToken).ConfigureAwait(false); + + while (await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + if (JsonNode.Parse(line) is not JsonObject frame) + { + continue; + } + + if (frame["id"] is JsonValue value && value.TryGetValue(out int responseId) && responseId == id) + { + return frame; + } + } + + throw new InvalidOperationException($"No response for request {id}."); + } + + /// Opens an initialize-era session on this same connection. + public async Task InitializeLegacyAsync(int id, CancellationToken cancellationToken) + { + await CallAsync( + id, + "initialize", + meta: null, + cancellationToken, + parameters: new JsonObject + { + ["protocolVersion"] = McpProtocolRevisions.LastWithSessions, + ["capabilities"] = new JsonObject(), + ["clientInfo"] = new JsonObject + { + ["name"] = "mixed-era-probe", + ["version"] = "1.0.0", + }, + }).ConfigureAwait(false); + + await NotifyAsync("notifications/initialized", cancellationToken).ConfigureAwait(false); + } + + public Task NotifyAsync(string method, CancellationToken cancellationToken) => + WriteAsync( + new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = method, + ["params"] = new JsonObject(), + }, + cancellationToken); + + public async ValueTask DisposeAsync() + { + _reader.Dispose(); + await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + } + + private async Task WriteAsync(JsonObject frame, CancellationToken cancellationToken) + { + var payload = Encoding.UTF8.GetBytes(frame.ToJsonString() + "\n"); + await clientToServer.Writer.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await clientToServer.Writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + [TestMethod] + [Description("Regression guard: two first invocations arriving together on one connection must share a single roots/list. Execution-boundary priming runs on every call, so without a shared in-flight fetch a burst of concurrent calls multiplies reverse requests to the client and contradicts the one-round-trip-per-connection cost this design claims. The second call is issued only once the first roots/list is known to be outstanding and unanswered, because two calls merely started together can still run one after the other \u2014 and a second call that reads a cache the first already filled pays one round-trip whether or not anything is shared.")] + public async Task When_TwoFirstCallsRaceOnOneConnection_Then_TheyShareOneRootsRoundTrip() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("peek", (IMcpClientRoots roots) => string.Join(',', roots.Current.Select(static r => r.Uri.ToString()))); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var roundTrips = 0; + var firstRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var session = await StartSessionAsync( + handler, + BuildParkedRootsClientOptions( + () => Interlocked.Increment(ref roundTrips), + firstRequested, + release.Task), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + var first = session.Client.CallToolAsync( + "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token); + + // Only now is the first fetch certainly in flight and certainly unanswered, which is the state a + // second caller has to arrive in for there to be anything to share. + await firstRequested.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token).ConfigureAwait(false); + + var second = session.Client.CallToolAsync( + "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token); + + // A second prime that did not join the outstanding fetch has to ask the client itself, and the + // counter moves when that ask is received rather than when it is answered \u2014 so an unshared fetch + // shows up here even though every answer is still held. + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false); + Volatile.Read(ref roundTrips).Should().Be( + 1, + because: "the second caller must join the fetch already in flight rather than start its own"); + + release.SetResult(); + var results = await Task.WhenAll(first.AsTask(), second.AsTask()).ConfigureAwait(false); + + foreach (var result in results) + { + result.Content.OfType().First().Text.Should().Contain("file:///ga"); + } + + Volatile.Read(ref roundTrips).Should().Be( + 1, + because: "the connection-scoped fetch is shared, so a race pays one roots/list, not one each"); + } + + [TestMethod] + [Description("Regression guard: roots/list_changed must retire the shared in-flight task, not only the cached array. That task exists to coalesce concurrent first calls, so a COMPLETED one left behind after an invalidation makes the next execution replay the pre-notification answer and send no roots/list at all \u2014 the cache is cleared and nothing refills it.")] + public async Task When_RootsListChangedFollowsACompletedFetch_Then_TheNextCallRefetches() + { + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var roundTrips = 0; + var session = await StartRootsVersionSessionAsync(() => Interlocked.Increment(ref roundTrips)).ConfigureAwait(false); + await using var scope = session.Session.ConfigureAwait(false); + + await using var registration = session.Session.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }).ConfigureAwait(false); + + (await PeekRootsAsync(session, session.Cts.Token).ConfigureAwait(false)).Should().Contain("workspace-v1"); + + await session.Session.Client.SendNotificationAsync( + NotificationMethods.RootsListChangedNotification, + cancellationToken: session.Cts.Token).ConfigureAwait(false); + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + (await PeekRootsAsync(session, session.Cts.Token).ConfigureAwait(false)).Should().Contain( + "workspace-v2", + because: "the invalidation must send the client back to the wire, not replay its previous answer"); + Volatile.Read(ref roundTrips).Should().Be(2); + } + + [TestMethod] + [Description("Regression guard: a roots/list answered after the client has invalidated its roots must be refetched rather than cached or reported. A fetch carries the version it started under, and a roots/list_changed processed while it is still unanswered makes that version stale \u2014 caching the late answer would pin roots the client already retracted, and handing it back would let the command run against roots it has already replaced. The notification is ordered against the unanswered fetch by holding the roots/list answer until the server echoes tools/list_changed, which it only emits once the invalidation has been applied.")] + public async Task When_RootsAreInvalidatedWhileTheFetchIsUnanswered_Then_TheLateAnswerIsNotCached() + { + var roundTrips = 0; + var firstRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("peek", (IMcpClientRoots roots) => string.Join(',', roots.Current.Select(static r => r.Uri.ToString()))); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + CreateHandler(app), + BuildParkedVersionedRootsClientOptions( + () => Interlocked.Increment(ref roundTrips), + firstRequested, + release.Task), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + await using var registration = session.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }).ConfigureAwait(false); + + var parked = session.Client.CallToolAsync( + "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token); + await firstRequested.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token).ConfigureAwait(false); + + await session.Client.SendNotificationAsync( + NotificationMethods.RootsListChangedNotification, + cancellationToken: cts.Token).ConfigureAwait(false); + + // The echo is the ordering: the server emits it from the same handler that bumps the version, so + // receiving it proves the version moved while the fetch above is still unanswered. + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token).ConfigureAwait(false); + + release.SetResult(); + var parkedResult = await parked.AsTask().ConfigureAwait(false); + + parkedResult.Content.OfType().First().Text.Should().Contain( + "workspace-v2", + because: "the call whose answer was retired asks again, so its command still gets a boundary"); + + var result = await session.Client.CallToolAsync( + "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.Content.OfType().First().Text.Should().Contain( + "workspace-v2", + because: "an answer that arrived after its version was retired must not be what the next caller reads"); + Volatile.Read(ref roundTrips).Should().Be( + 2, + because: "the retired answer left nothing cached, so the next caller goes back to the client"); + } + + [TestMethod] + [Description("Regression guard: a roots fetch that ends badly must be retracted, so the next caller reaches the client again instead of inheriting the failure. Without it a single unusable answer latches for the life of the connection, and the caller that would have retried is the one that never learns there was anything to retry.")] + public async Task When_AConnectionScopedFetchFails_Then_TheNextCallerReachesTheClientAgain() + { + var roundTrips = 0; + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("resolve", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(static r => r.Uri.ToString()))); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + CreateHandler(app), + BuildFirstAnswerUnusableRootsClientOptions(() => Interlocked.Increment(ref roundTrips)), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + // The execution prologue absorbs the unusable first answer and swallows it; the handler's own + // GetAsync is the next caller, and must not inherit that failure. + var result = await session.Client.CallToolAsync( + toolName: "resolve", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.Content.OfType().First().Text.Should().Contain( + "file:///recovered", + because: "the failed fetch must be retracted, not handed to everyone who asks next"); + Volatile.Read(ref roundTrips).Should().Be(2); + } + + [TestMethod] + [Description("Regression guard: when a roots-capable client cannot be resolved, Current must fall back to the soft roots rather than report an empty set. The eager prime absorbs the failure so that commands which never read roots still run, which leaves Current answering for a resolution that never happened \u2014 and an empty answer reads as \u0027this client declared no roots\u0027, which is the reading a handler acts on and the one it cannot check. A client that genuinely answers with zero roots is still told apart, because that answer is recorded as resolved.")] + public async Task When_TheNativeFetchFails_Then_CurrentFallsBackToSoftRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("peek", (IMcpClientRoots roots) => + { + // The prime has already run and failed by the time a handler body executes. + roots.SetSoftRoots([new McpClientRoot(new Uri("file:///soft", UriKind.Absolute), "soft")]); + return string.Join(',', roots.Current.Select(static r => r.Uri.ToString())); + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + CreateHandler(app), + BuildUnusableRootsClientOptions(), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + var result = await session.Client.CallToolAsync( + toolName: "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.Content.OfType().First().Text.Should().Contain( + "file:///soft", + because: "nothing native was resolved, so the roots actually in force are the soft ones"); + } + + [TestMethod] + [Description("Regression guard: a direct GetAsync waiter must never be handed an answer that was retired while it was in flight. Refusing to cache such an answer is not enough \u2014 returning it gives the caller roots the client has already retracted, with nothing to tell it apart from a current one. The command boundary masks this, because a later prime refetches before the handler reads Current; only the value GetAsync itself returns shows it.")] + public async Task When_AnInFlightFetchIsRetired_Then_TheDirectWaiterIsNotHandedIt() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var proceed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var echo = new SemaphoreSlim(0); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync( + CreateHandler(BuildDirectWaiterApp(entered, proceed.Task)), + BuildParkOnSecondRootsClientOptions(secondStarted, release.Task), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + await using var registration = session.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + echo.Release(); + return ValueTask.CompletedTask; + }).ConfigureAwait(false); + + var call = session.Client.CallToolAsync( + toolName: "resolve", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token); + + // The prime has resolved and cached workspace-v1, and the handler is parked before its own read. + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token).ConfigureAwait(false); + await InvalidateAndAwaitEchoAsync(session, echo, cts.Token).ConfigureAwait(false); + + // Now the handler asks for itself, and that fetch is the one held unanswered. + proceed.SetResult(); + await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token).ConfigureAwait(false); + await InvalidateAndAwaitEchoAsync(session, echo, cts.Token).ConfigureAwait(false); + + release.SetResult(); + var result = await call.AsTask().ConfigureAwait(false); + + result.Content.OfType().First().Text.Should().Contain( + "workspace-v3", + because: "the answer that arrived after its version was retired must not be what the caller receives"); + } + + private static ReplApp BuildDirectWaiterApp(TaskCompletionSource entered, Task proceed) + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("resolve", async (IMcpClientRoots roots, CancellationToken ct) => + { + entered.TrySetResult(); +#pragma warning disable VSTHRD003 // A gate owned by the test, completed by the test. + await proceed.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + var resolved = await roots.GetAsync(ct).ConfigureAwait(false); + return string.Join(',', resolved.Select(static r => r.Uri.ToString())); + }); + + return app; + } + + private static async Task InvalidateAndAwaitEchoAsync( + McpPipeSession session, + SemaphoreSlim echo, + CancellationToken cancellationToken) + { + await session.Client.SendNotificationAsync( + NotificationMethods.RootsListChangedNotification, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // The server emits tools/list_changed from the same handler that moves the roots version, so the + // echo proves the invalidation has been applied rather than merely sent. + await echo.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + } + + /// A client that holds only its second roots/list, and versions every answer. + private static McpClientOptions BuildParkOnSecondRootsClientOptions( + TaskCompletionSource secondStarted, + Task release) + { + var calls = 0; + var options = BuildRootsClientOptions("file:///unused"); + // roots/list_changed was removed from 2026-07-28 by SEP-2575, so this guard belongs to the + // initialize era. The defect it pins is in the roots service and is era-independent. + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + options.Handlers = new McpClientHandlers + { + RootsHandler = async (_, _) => + { + var n = Interlocked.Increment(ref calls); + if (n == 2) + { + secondStarted.TrySetResult(); +#pragma warning disable VSTHRD003 // A gate owned by the test, completed by the test. + await release.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + + var uri = "file:///workspace-v" + n.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new ListRootsResult + { + Roots = [new Root { Uri = uri, Name = "workspace" }], + }; + }, + }; + + return options; + } + + /// A roots-capable client whose every answer fails while being mapped. + private static McpClientOptions BuildUnusableRootsClientOptions() + { + var options = BuildRootsClientOptions("file:///unused"); + options.Handlers = new McpClientHandlers + { + // Fails server-side while being mapped, which is a real fetch failure and needs no + // client-side throw \u2014 a handler that throws escapes the SDK's own message loop. + RootsHandler = static (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "http://", Name = "workspace" }], + }), + }; + + return options; + } + + /// A client whose first roots/list answer cannot be mapped, and whose next can. + private static McpClientOptions BuildFirstAnswerUnusableRootsClientOptions(Action onRequest) + { + var calls = 0; + var options = BuildRootsClientOptions("file:///unused"); + options.Handlers = new McpClientHandlers + { + // The first answer fails server-side while being mapped, which is a real fetch failure and + // needs no client-side throw — a handler that throws escapes the SDK's own message loop. + RootsHandler = (_, _) => + { + onRequest(); + var uri = Interlocked.Increment(ref calls) == 1 ? "http://" : "file:///recovered"; + return ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = uri, Name = "workspace" }], + }); + }, + }; + + return options; + } + + private static async Task PeekRootsAsync(RootsVersionSession session, CancellationToken cancellationToken) + { + var result = await session.Session.Client.CallToolAsync( + toolName: "peek", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.Content.OfType().First().Text; + } + + private sealed record RootsVersionSession(McpPipeSession Session, CancellationTokenSource Cts); + + private static async Task StartRootsVersionSessionAsync(Action onRequest) + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("peek", (IMcpClientRoots roots) => string.Join(',', roots.Current.Select(static r => r.Uri.ToString()))); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var session = await StartSessionAsync( + CreateHandler(app), + BuildVersionedRootsClientOptions(onRequest), + cts.Token).ConfigureAwait(false); + + return new RootsVersionSession(session, cts); + } + + private sealed class GateProbeModule(string commandName) : IReplModule + { + public void Map(IReplMap app) => app.Map(commandName, () => "ok").ReadOnly(); + } + + /// A client whose every roots/list answer names a new workspace version. + private static McpClientOptions BuildVersionedRootsClientOptions(Action onRequest) + { + var version = 0; + var options = BuildRootsClientOptions("file:///unused"); + // roots/list_changed was removed from 2026-07-28 by SEP-2575, and that revision delivers no + // unsolicited list_changed either, so this guard belongs to the initialize era. The defect it + // pins is in the roots service and is era-independent. + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + options.Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => + { + onRequest(); + var uri = "file:///workspace-v" + Interlocked.Increment(ref version).ToString( + System.Globalization.CultureInfo.InvariantCulture); + return ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = uri, Name = "workspace" }], + }); + }, + }; + + return options; + } + + /// + /// A client that holds its first roots/list answer until told to let go, so a test can work + /// with a fetch that is certainly outstanding and certainly unanswered. + /// + /// + /// The count is taken on arrival rather than on the answer: a second fetch has to be visible while + /// every answer is still held, which is the whole point of holding them. + /// + private static McpClientOptions BuildParkedRootsClientOptions( + Action onRequest, + TaskCompletionSource firstRequested, + Task release) + { + var options = BuildRootsClientOptions("file:///ga"); + options.Handlers = new McpClientHandlers + { + RootsHandler = async (_, _) => + { + onRequest(); + firstRequested.TrySetResult(); +#pragma warning disable VSTHRD003 // A gate owned by the test, completed by the test. + await release.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + return new ListRootsResult + { + Roots = [new Root { Uri = "file:///ga", Name = "ga" }], + }; + }, + }; + + return options; + } + + /// + /// with a workspace version per answer, so a caller can + /// be told apart by which answer it read. + /// + private static McpClientOptions BuildParkedVersionedRootsClientOptions( + Action onRequest, + TaskCompletionSource firstRequested, + Task release) + { + var version = 0; + var options = BuildRootsClientOptions("file:///unused"); + // roots/list_changed was removed from 2026-07-28 by SEP-2575, so this guard belongs to the + // initialize era. The defect it pins is in the roots service and is era-independent. + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + options.Handlers = new McpClientHandlers + { + RootsHandler = async (_, _) => + { + onRequest(); + var uri = "file:///workspace-v" + Interlocked.Increment(ref version).ToString( + System.Globalization.CultureInfo.InvariantCulture); + if (firstRequested.TrySetResult()) + { +#pragma warning disable VSTHRD003 // A gate owned by the test, completed by the test. + await release.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + + return new ListRootsResult + { + Roots = [new Root { Uri = uri, Name = "workspace" }], + }; + }, + }; + + return options; + } + + private static McpClientOptions BuildCountingRootsClientOptions(Action onRequest) + { + var options = BuildRootsClientOptions("file:///ga"); + options.Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => + { + onRequest(); + return ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "file:///ga", Name = "ga" }], + }); + }, + }; + return options; + } + + private static McpClientOptions BuildLegacyRootsClientOptions(string rootUri) + { + var options = BuildRootsClientOptions(rootUri); + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + return options; + } + + private static McpClientOptions BuildRootsClientOptions(string rootUri) => new() + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = rootUri, Name = rootUri }], + }), + }, + }; + + // Sampling carries the same SEP-2577 deprecation as Roots above. + private static McpClientOptions BuildSamplingClientOptions() => new() + { + Capabilities = new ClientCapabilities { Sampling = new SamplingCapability() }, + Handlers = new McpClientHandlers + { + SamplingHandler = static (request, _, _) => ValueTask.FromResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "ga" }], + Model = "test-model", + }), + }, + }; +#pragma warning restore MCP9005 } diff --git a/src/Repl.McpTests/Given_McpDebounce.cs b/src/Repl.McpTests/Given_McpDebounce.cs index 72d79567..c5dcd17e 100644 --- a/src/Repl.McpTests/Given_McpDebounce.cs +++ b/src/Repl.McpTests/Given_McpDebounce.cs @@ -72,6 +72,37 @@ public void When_RebuildThrows_Then_ServerContinuesWithStaleRoutes() recoveredTools.Should().Contain(tool => string.Equals(tool.Name, "added-after", StringComparison.Ordinal)); } + [TestMethod] + [Description("Regression guard: verifies the availability fallback keeps applying after a visibility retraction. Republishing a served-but-stale snapshot used to overwrite the version it was built at with a zero sentinel, so the retraction watermark was compared against zero and read as older than every retraction ever published. The FIRST failed projection still served the previous catalog and the second surfaced the error instead — a catalog that had been serving a moment earlier became unreachable for as long as the failure lasted. Hiding a command is the retraction: without one the watermark stays at zero, where the sentinel happened to compare equal and the defect is invisible.")] + public void When_ProjectionKeepsFailingAfterARetraction_Then_ThePreviousCatalogKeepsServing() + { + var fakeTime = new FakeTimeProvider(); + using var fixture = CreateServerFixture(fakeTime); + var extra = fixture.App.Map("extra", static () => "x"); + + SyncWait(fixture.Client.ListToolsAsync().AsTask()) + .Should().Contain(tool => string.Equals(tool.Name, "extra", StringComparison.Ordinal)); + + // Hiding a mapped command publishes a visibility retraction, which moves the watermark the + // availability fallback compares its cached snapshot against. + extra.Hidden(); + SyncWait(fixture.Client.ListToolsAsync().AsTask()) + .Should().NotContain(tool => string.Equals(tool.Name, "extra", StringComparison.Ordinal)); + + // From here every projection throws. + fixture.Options.CommandFilter = _ => throw new InvalidOperationException("Simulated rebuild failure"); + fixture.App.Core.InvalidateRouting(); + fakeTime.Advance(TimeSpan.FromMilliseconds(150)); + + var first = SyncWait(fixture.Client.ListToolsAsync().AsTask()); + var second = SyncWait(fixture.Client.ListToolsAsync().AsTask()); + + first.Should().ContainSingle(tool => string.Equals(tool.Name, "initial", StringComparison.Ordinal)); + second.Should().ContainSingle( + tool => string.Equals(tool.Name, "initial", StringComparison.Ordinal), + because: "the second read must not lose the fallback the first one just used"); + } + [TestMethod] [Description("Pausing immediately before invalidation publication leaves readers on the complete old version/watermark pair; releasing publication exposes the complete new pair atomically.")] public void When_VisibilityRetractionPublicationIsPaused_Then_ReaderObservesOnlyCompleteStates() diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 55de3709..f917e992 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,3 +1,5 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Repl.Mcp; namespace Repl.McpTests; @@ -74,7 +76,10 @@ public void When_BuildingMcpOptions_Then_LoggingCapabilityIsAdvertised() var options = app.BuildMcpServerOptions(); + // Logging is deprecated (SEP-2577, MCP9005) but still supported by Repl.Mcp (#51). +#pragma warning disable MCP9005 options.Capabilities!.Logging.Should().NotBeNull(); +#pragma warning restore MCP9005 } [TestMethod] @@ -117,6 +122,83 @@ public void When_EnrichedCommands_Then_DocModelContainsAllFields() cmd.Arguments.Should().ContainSingle(a => string.Equals(a.Name, "env", StringComparison.Ordinal)); } + [TestMethod] + [Description("Regression guard: verifies the 2026-07-28 cache hints stay off an initialize-era response. CacheScope and TimeToLive were added by SEP-2549 and are absent from the legacy result schema, so a strict client on the compatibility path this PR exists to preserve can reject a response carrying them. Probed on tools; the handler tags all four list results through one helper.")] + public async Task When_ClientPinsLegacyProtocolVersion_Then_ListResultsCarryNoCacheHints() + { + var clientOptions = new McpClientOptions + { + ProtocolVersion = McpProtocolRevisions.LastWithSessions, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("ping", () => "pong"), + configureOptions: null, + clientOptions: clientOptions); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var tools = await fixture.Client.SendRequestAsync( + RequestMethods.ToolsList, new ListToolsRequestParams(), cancellationToken: default).ConfigureAwait(false); + + tools.CacheScope.Should().BeNull(because: "cacheScope is a 2026-07-28 field"); + tools.TimeToLive.Should().BeNull(); + } + + [TestMethod] + [Description("Locks the legacy initialize handshake under SDK 2.0: a client pinning an initialize-era protocol revision still negotiates that exact version and can list and call tools — the fixture's default client otherwise negotiates the 2026-07-28 path and never exercises the fallback.")] + public async Task When_ClientPinsLegacyProtocolVersion_Then_InitializeHandshakeAndToolsWork() + { + var clientOptions = new McpClientOptions + { + ProtocolVersion = McpProtocolRevisions.LastWithSessions, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("ping", () => "pong"), + configureOptions: null, + clientOptions: clientOptions); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().ContainSingle(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); + + var result = await fixture.Client.CallToolAsync( + toolName: "ping", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + result.Content.OfType().First().Text.Should().Contain("pong"); + } + + [TestMethod] + [Description("Locks the tools/list wire shape for .LongRunning(): the annotation is Repl-local metadata and must leave no trace on the protocol surface until Repl integrates the SDK Tasks extension (issue #72). Asserted by serializing two otherwise identical tools that differ only by .LongRunning() and requiring byte-identical payloads — a NotContain(\"execution\") assertion could not fail, since SDK 2.x removed Tool.Execution entirely.")] + public void When_SerializingLongRunningTool_Then_WireShapeIsUnchanged() + { + var app = ReplApp.Create(); + app.Map("deploy", () => "deployed") + .WithDescription("Deploy application") + .LongRunning() + .OpenWorld(); + app.Map("release", () => "released") + .WithDescription("Deploy application") + .OpenWorld(); + + var options = app.BuildMcpServerOptions(); + + SerializeTool(options, "deploy").Should().Be( + SerializeTool(options, "release").Replace("\"release\"", "\"deploy\"", StringComparison.Ordinal), + because: ".LongRunning() must not change anything a client can observe"); + } + + private static string SerializeTool(ModelContextProtocol.Server.McpServerOptions options, string name) + { + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, name, StringComparison.Ordinal)); + + return System.Text.Json.JsonSerializer.Serialize( + tool.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions); + } + [TestMethod] [Description("Modules excluded from Programmatic channel are not visible as MCP tools.")] public void When_ModuleExcludedFromProgrammatic_Then_NotInToolCandidates() diff --git a/src/Repl.McpTests/Given_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index 79831561..52de451f 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -255,7 +255,8 @@ public async Task When_ResourceCommandFails_Then_ReadThrowsMcpException() public async Task When_ResourceRouteIsUnknown_Then_AdapterReturnsTextError() { await using var services = new ServiceCollection().BuildServiceProvider(); - var adapter = new McpToolAdapter(ReplApp.Create().Core, new ReplMcpServerOptions(), services); + var adapter = new McpToolAdapter( + ReplApp.Create().Core, new ReplMcpServerOptions(), services, new McpRequestServerAccessor()); var result = await adapter.InvokeResourceAsync( "missing", diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index ed70c18b..1a567253 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -1,9 +1,14 @@ -using System.Text.Json; +using System.Text.Json; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] @@ -55,18 +60,59 @@ public async Task When_ClientSupportsRoots_Then_RootAwareToolCanReadThem() } [TestMethod] - [Description("Soft roots can initialize MCP-only commands when native roots are unavailable.")] - public async Task When_ClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace() + [Description("Regression guard: a handler that reads IMcpClientRoots.Current WITHOUT calling GetAsync must still see the client's workspace on its very first invocation. A modern revision forbids discovery from depending on the connection, so discovery makes no roots round-trip at all and the resolution happens at the execution boundary instead. The sibling test above calls GetAsync explicitly, which is exactly why it cannot catch this.")] + public async Task When_AModernHandlerReadsCurrentWithoutAsking_Then_TheFirstCallSeesTheClientRoots() { - await using var fixture = await McpTestFixture.CreateAsync(configure: app => + var clientOptions = new McpClientOptions { - app.MapModule( - new SoftRootsInitModule(), - (IMcpClientRoots roots) => !roots.IsSupported); - app.MapModule( - new SoftRootsWorkspaceModule(), - (IMcpClientRoots roots) => !roots.IsSupported && roots.HasSoftRoots); - }); + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = static (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "file:///C:/workspace", Name = "workspace" }], + }), + }, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.MapModule(new CurrentOnlyRootsModule()), + configureOptions: null, + clientOptions: clientOptions); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + // The FIRST call: nothing has resolved roots for this connection yet. + var result = await fixture.Client.CallToolAsync( + toolName: "roots_peek", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + result.Content.OfType().First().Text.Should().Contain( + "file:///C:/workspace", + because: "Current is documented as the client's effective roots, not as a cache the handler must prime"); + } + + [TestMethod] + [Description("Soft roots can initialize MCP-only commands when native roots are unavailable. Pinned to an initialize-era revision: that is where a per-session tool graph is legal, and where revealing a command as a side effect of a tools/call is not forbidden.")] + public async Task When_LegacyClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace() + { + await using var fixture = await McpTestFixture.CreateAsync( + configure: app => + { + app.MapModule( + new SoftRootsInitModule(), + (IMcpClientRoots roots) => !roots.IsSupported); + app.MapModule( + new SoftRootsWorkspaceModule(), + (IMcpClientRoots roots) => !roots.IsSupported && roots.HasSoftRoots); + }, + configureOptions: null, + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); var before = await fixture.Client.ListToolsAsync().ConfigureAwait(false); before.Should().Contain(t => string.Equals(t.Name, "softroots_init", StringComparison.Ordinal)); @@ -89,6 +135,42 @@ await fixture.Client.CallToolAsync( text.Should().Contain("file:///C:/soft-workspace"); } + [TestMethod] + [Description("Regression guard: soft roots keep working on 2026-07-28, where they no longer reveal commands. That revision forbids the advertised set from varying as a side effect of other requests on the connection — which a graph gated on HasSoftRoots would do, with a single connection being enough to observe it. Mapped unconditionally, the same commands still set the soft roots and still read them back, and the set is identical before and after.")] + public async Task When_ModernClientSetsSoftRoots_Then_TheyResolveWithoutChangingTheAdvertisedSet() + { + await using var fixture = await McpTestFixture.CreateAsync(configure: app => + { + app.MapModule(new SoftRootsInitModule()); + app.MapModule(new SoftRootsWorkspaceModule()); + }); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var before = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + before.Should().Contain(t => string.Equals(t.Name, "softroots_init", StringComparison.Ordinal)); + before.Should().Contain(t => string.Equals(t.Name, "softroots_show", StringComparison.Ordinal)); + + await fixture.Client.CallToolAsync( + "softroots_init", + arguments: new Dictionary(StringComparer.Ordinal) + { + ["path"] = "file:///C:/soft-workspace", + }).ConfigureAwait(false); + + var after = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + after.Select(static t => t.Name).Should().BeEquivalentTo( + before.Select(static t => t.Name), + because: "a tools/call must not change the set advertised on the same connection"); + + var show = await fixture.Client.CallToolAsync( + toolName: "softroots_show", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + show.Content.OfType().First().Text.Should().Contain( + "file:///C:/soft-workspace", + because: "execution still sees the soft roots; only the automatic revealing is gone"); + } + [TestMethod] [Description("The opt-in compatibility shim exposes discover_tools and call_tool before the real tool list is refreshed.")] public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAndCallThroughShim() @@ -100,7 +182,11 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd { app.Map("echo {msg}", (string msg) => $"echo:{msg}"); }, - configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + // The shim exists for clients that do not refresh a changing tool list — i.e. initialize-era + // clients. Pinning the CLIENT (not the server) keeps the server multi-revision while making + // this test state which revision its unsolicited-notification expectation belongs to. + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }); await using var registration = fixture.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, @@ -157,7 +243,11 @@ public async Task When_RoutingChanges_AfterCompatibilityIntro_Then_ShimIsServedA { app.Map("echo {msg}", (string msg) => $"echo:{msg}"); }, - configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + // The shim exists for clients that do not refresh a changing tool list — i.e. initialize-era + // clients. Pinning the CLIENT (not the server) keeps the server multi-revision while making + // this test state which revision its unsolicited-notification expectation belongs to. + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }); await using var registration = fixture.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, @@ -210,6 +300,16 @@ public void Map(IReplMap app) } } + private sealed class CurrentOnlyRootsModule : IReplModule + { + public void Map(IReplMap app) => + app.Map( + "roots peek", + (IMcpClientRoots roots) => + string.Join(',', roots.Current.Select(static root => root.Uri.ToString()))) + .ReadOnly(); + } + private sealed class SoftRootsInitModule : IReplModule { public void Map(IReplMap app) diff --git a/src/Repl.McpTests/Given_McpSharedServerOptions.cs b/src/Repl.McpTests/Given_McpSharedServerOptions.cs new file mode 100644 index 00000000..3cf0f7aa --- /dev/null +++ b/src/Repl.McpTests/Given_McpSharedServerOptions.cs @@ -0,0 +1,571 @@ +using System.Diagnostics; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +namespace Repl.McpTests; + +/// +/// Regressions for the documented multi-connection host pattern: build +/// BuildMcpServerOptions() ONCE and create an per connection +/// (docs/mcp-transports.md). That path bypasses 's request handlers +/// entirely — the SDK dispatches straight into the pre-built primitives — so nothing that relies on +/// the handler prologue applies to it. +/// +[TestClass] +public sealed class Given_McpSharedServerOptions +{ + [TestMethod] + [Description("Guards capability resolution on the documented reusable-options path: two connections created from ONE BuildMcpServerOptions() result must each observe their OWN client's capabilities. The pre-built primitives never run the handler's request prologue, so without per-invocation request binding a sampling-capable client is told sampling is unavailable — the capability is resolved against nothing at all.")] + public async Task When_TwoConnectionsShareOneOptionsInstance_Then_CapabilitiesAreRequestScoped() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + // Distinct tokens, not "supported"/"not-supported": the tool result is JSON, so the text block + // carries quotes, and a substring assertion on the shorter word would match both answers. + app.Map("probe", (IMcpSampling sampling) => sampling.IsSupported ? "sampling-on" : "sampling-off"); + + // Built once and reused across connections, exactly as docs/mcp-transports.md prescribes. + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var capable = await StartAsync(mcpOptions, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + await using var capableScope = capable.ConfigureAwait(false); + var plain = await StartAsync(mcpOptions, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var plainScope = plain.ConfigureAwait(false); + + capable.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + plain.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var capableText = await CallProbeAsync(capable, cts.Token).ConfigureAwait(false); + var plainText = await CallProbeAsync(plain, cts.Token).ConfigureAwait(false); + + capableText.Should().Contain("sampling-on"); + plainText.Should().Contain("sampling-off"); + } + + [TestMethod] + [Description("Guards native-root isolation on the documented reusable-options path: two connections created from ONE BuildMcpServerOptions() result must each observe their OWN client's workspace roots. The pre-built primitives share a single McpClientRootsService, so a cache keyed to that instance hands the second client the first client's filesystem URIs without ever asking it — a cross-client disclosure, not merely a stale read.")] + public async Task When_TwoRootCapableClientsShareOneOptionsInstance_Then_EachSeesOwnRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("roots", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(root => root.Uri.ToString()))); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var first = await StartAsync(mcpOptions, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var firstScope = first.ConfigureAwait(false); + var second = await StartAsync(mcpOptions, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + await using var secondScope = second.ConfigureAwait(false); + + var firstText = await CallAsync(first, "roots", cts.Token).ConfigureAwait(false); + var secondText = await CallAsync(second, "roots", cts.Token).ConfigureAwait(false); + + firstText.Should().Contain("file:///ga"); + secondText.Should().Contain("file:///bu"); + secondText.Should().NotContain("file:///ga", because: "one client's workspace must never reach another"); + } + + [TestMethod] + [Description("Guards the other read the shared cache leaks through: Current returns the cached hard roots whenever the flowing request declares the roots capability, so a client that never asked for roots itself still receives whatever the previous connection's roots/list returned. Reading Current is the documented cheap path for a command that does not want a round-trip, which is exactly why it must not answer with someone else's workspace.")] + public async Task When_ASecondClientReadsCurrentRoots_Then_ItDoesNotSeeTheFirstClientsRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("fetch", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(root => root.Uri.ToString()))); + app.Map("peek", (IMcpClientRoots roots) => + $"[{string.Join(',', roots.Current.Select(root => root.Uri.ToString()))}]"); + app.Map("fetch-then-peek", async (IMcpClientRoots roots, CancellationToken ct) => + { + await roots.GetAsync(ct).ConfigureAwait(false); + return $"[{string.Join(',', roots.Current.Select(root => root.Uri.ToString()))}]"; + }); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var first = await StartAsync(mcpOptions, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var firstScope = first.ConfigureAwait(false); + var second = await StartAsync(mcpOptions, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + await using var secondScope = second.ConfigureAwait(false); + + // The first connection populates whatever cache exists; the second only ever peeks. + (await CallAsync(first, "fetch", cts.Token).ConfigureAwait(false)).Should().Contain("file:///ga"); + + var peeked = await CallAsync(second, "peek", cts.Token).ConfigureAwait(false); + + peeked.Should().NotContain("file:///ga"); + + // The other half: once this request has resolved, Current must answer with what it resolved. + // Without this, removing the resolved-value path entirely would keep the assertion above green + // while leaving Current permanently empty. + var fetched = await CallAsync(second, "fetch-then-peek", cts.Token).ConfigureAwait(false); + + fetched.Should().Contain("file:///bu"); + fetched.Should().NotContain("file:///ga"); + } + + [TestMethod] + [Description("Pins the cost of the isolation above: resolving roots twice inside ONE tool invocation must still cost a single roots/list round-trip. Isolating per request rather than per connection is only correct if it memoises within the request — otherwise every IMcpClientRoots call becomes a client round-trip, which would trade a disclosure for a latency regression.")] + public async Task When_OneRequestResolvesRootsTwice_Then_OnlyOneRootsListRoundTrip() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("twice", async (IMcpClientRoots roots, CancellationToken ct) => + { + var once = await roots.GetAsync(ct).ConfigureAwait(false); + var twice = await roots.GetAsync(ct).ConfigureAwait(false); + return $"{once.Count}|{twice.Count}"; + }); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + var roundTrips = 0; + + var session = await StartAsync( + mcpOptions, + BuildRootsClientOptions("file:///ga", () => Interlocked.Increment(ref roundTrips)), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + var text = await CallAsync(session, "twice", cts.Token).ConfigureAwait(false); + + text.Should().Contain("1|1"); + Volatile.Read(ref roundTrips).Should().Be(1); + } + + [TestMethod] + [Description("Regression guard: a caller that gives up on roots must be released when ITS token fires, not when the service's internal fetch budget expires. The fetch is shared so one waiter abandoning it cannot cancel the answer the others are waiting for — but that is a reason to keep the fetch independent, not a reason to ignore the caller. A client that never answers roots/list would otherwise pin the handler for the full budget after the request is already gone.")] + public async Task When_ACallerCancelsWhileRootsAreOutstanding_Then_ItIsReleasedOnItsOwnToken() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("give-up", async (IMcpClientRoots roots, CancellationToken ct) => + { + using var caller = CancellationTokenSource.CreateLinkedTokenSource(ct); + caller.CancelAfter(CallerPatience); + + // Measured inside the handler: what is under test is how long the service holds this caller, + // not how long the round-trip to the test client takes. + var waited = Stopwatch.StartNew(); + try + { + await roots.GetAsync(caller.Token).ConfigureAwait(false); + return $"resolved|{waited.ElapsedMilliseconds}"; + } + catch (OperationCanceledException) + { + return $"cancelled|{waited.ElapsedMilliseconds}"; + } + }); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var session = await StartAsync(mcpOptions, BuildSlowRootsClientOptions(), cts.Token) + .ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + // Trimmed because the tool result is JSON: the text block carries the surrounding quotes. + var text = (await CallAsync(session, "give-up", cts.Token).ConfigureAwait(false)).Trim('"'); + var parts = text.Split('|'); + + parts[0].Should().Be( + "cancelled", + because: "the caller gave up long before the client answered, so it must not receive the answer"); + int.Parse(parts[1], System.Globalization.CultureInfo.InvariantCulture) + .Should().BeLessThan( + (int)ReleasedWithin.TotalMilliseconds, + because: "the caller's token must release it, not the service's own 10-second fetch budget"); + } + + [TestMethod] + [Description("Regression guard: an MCP App UI resource handler must see the flowing request like every other prebuilt primitive. On the reusable-options path the SDK dispatches straight into the resource, so a handler injecting a capability service resolves it against nothing and reports a capable client as incapable — the same defect the tool path was fixed for, in the one primitive that never bound its request.")] + public async Task When_AUiResourceHandlerInjectsACapability_Then_ItResolvesAgainstTheCallersRequest() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + + // Configured here rather than on UseMcpServer: BuildMcpServerOptions builds its own + // ReplMcpServerOptions, so this is the callback that reaches the reusable-options path. + var mcpOptions = app.BuildMcpServerOptions(options => options.UiResource( + "ui://probe/capability", + (IMcpSampling sampling) => sampling.IsSupported + ? "sampling-on" + : "sampling-off")); + using var cts = new CancellationTokenSource(); + + var capable = await StartAsync(mcpOptions, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + await using var capableScope = capable.ConfigureAwait(false); + var plain = await StartAsync(mcpOptions, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var plainScope = plain.ConfigureAwait(false); + + var capableHtml = await ReadUiAsync(capable, cts.Token).ConfigureAwait(false); + var plainHtml = await ReadUiAsync(plain, cts.Token).ConfigureAwait(false); + + capableHtml.Should().Contain("sampling-on"); + plainHtml.Should().Contain("sampling-off"); + } + + [TestMethod] + [Description("Regression guard: a roots/list that fails must be retracted, not memoised. The per-request entry shares one fetch so that resolving roots twice costs one round-trip — which must not turn a single transient failure into a permanent one for the rest of the request. A later call has to be able to try again.")] + public async Task When_TheFirstRootsRequestFails_Then_ALaterCallInTheSameRequestTriesAgain() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("retry", async (IMcpClientRoots roots, CancellationToken ct) => + { + try + { + await roots.GetAsync(ct).ConfigureAwait(false); + return "first-call-should-have-failed"; + } + catch (Exception) + { + var retried = await roots.GetAsync(ct).ConfigureAwait(false); + return string.Join(',', retried.Select(root => root.Uri.ToString())); + } + }); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var session = await StartAsync(mcpOptions, BuildFlakyRootsClientOptions(), cts.Token) + .ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + var text = await CallAsync(session, "retry", cts.Token).ConfigureAwait(false); + + text.Should().Contain("file:///ga"); + } + + [TestMethod] + [Description("Regression guard: the cross-product of the two roots guards above — the last waiter stops waiting AND the shared fetch fails afterwards. Nothing is left to observe that failure, so a fetch retracted only when a waiter sees it throw stays cached, and the next call in the same request replays a failure that is already over instead of issuing the promised retry.")] + public async Task When_TheLastWaiterLeavesBeforeTheFetchFails_Then_ALaterCallStillRetries() + { + var firstAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("abandon-then-retry", async (IMcpClientRoots roots, CancellationToken ct) => + { + using var impatient = CancellationTokenSource.CreateLinkedTokenSource(ct); + impatient.CancelAfter(CallerPatience); + try + { + await roots.GetAsync(impatient.Token).ConfigureAwait(false); + return "the-first-call-should-have-been-abandoned"; + } + catch (OperationCanceledException) + { + // Expected: nobody is waiting on the fetch from here on. + } + + // The fetch fails after its last waiter has gone: the client answers with an unparseable + // URI, so mapping it throws server-side, with nothing left to observe the failure. + await firstAnswered.Task.WaitAsync(ct).ConfigureAwait(false); + await Task.Delay(FaultSettlingDelay, ct).ConfigureAwait(false); + + var recovered = await roots.GetAsync(ct).ConfigureAwait(false); + return string.Join(',', recovered.Select(root => root.Uri.ToString())); + }); + + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var session = await StartAsync( + mcpOptions, + BuildAbandonedThenRecoveringRootsClientOptions(firstAnswered), + cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + var text = await CallAsync(session, "abandon-then-retry", cts.Token).ConfigureAwait(false); + + text.Should().Contain( + "file:///recovered", + because: "the failed fetch must be retracted even though no caller was left to observe it"); + } + + [TestMethod] + [Description("Regression guard: the Apps extension capability must cover whatever the catalog can contain. Capabilities are declared once, before any request names an era or a caller, while the catalog that reaches a client is resolved later — so an App behind a presence gate must be advertised anyway, or a client that is served it holds metadata with nothing to interpret it.")] + public async Task When_ACapabilityGatedAppIsInTheCatalog_Then_TheAppsExtensionIsAdvertised() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.MapModule(new GatedAppModule(), (IMcpClientRoots roots) => roots.IsSupported); + + var mcpOptions = app.BuildMcpServerOptions(); + +#pragma warning disable MCPEXP001 + mcpOptions.Capabilities?.Extensions.Should().NotBeNull() + .And.ContainKey( + McpAppMetadata.ExtensionName, + because: "the catalog this options instance serves contains an MCP App resource"); +#pragma warning restore MCPEXP001 + } + + [TestMethod] + [Description("Guards the opposite direction of the same rule: an MCP App behind a negated capability gate must be advertised too. Any probe that answered by evaluating the gates would get exactly one of these two cases wrong, whichever way it resolved them, so both stay pinned.")] + public async Task When_AnAppIsGatedOnAMissingCapability_Then_TheAppsExtensionIsStillAdvertised() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.MapModule(new GatedAppModule(), (IMcpClientRoots roots) => !roots.IsSupported); + + var mcpOptions = app.BuildMcpServerOptions(); + +#pragma warning disable MCPEXP001 + mcpOptions.Capabilities?.Extensions.Should().NotBeNull() + .And.ContainKey( + McpAppMetadata.ExtensionName, + because: "this catalog registers an MCP App resource, and what the gate would decide is " + + "not knowable when capabilities are declared"); +#pragma warning restore MCPEXP001 + } + + private sealed class GatedAppModule : IReplModule + { + public void Map(IReplMap app) => + app.Map("dashboard", () => "ok") + .ReadOnly() + .AsMcpAppResource("ui://gated/dashboard"); + } + + private static async Task ReadUiAsync(McpPipeSession session, CancellationToken cancellationToken) + { + var result = await session.Client.ReadResourceAsync( + uri: "ui://probe/capability", + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.Contents.OfType().Single().Text; + } + + private static Task CallProbeAsync(McpPipeSession session, CancellationToken cancellationToken) => + CallAsync(session, "probe", cancellationToken); + + private static async Task CallAsync( + McpPipeSession session, + string toolName, + CancellationToken cancellationToken) + { + var result = await session.Client.CallToolAsync( + toolName: toolName, + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.Content.OfType().First().Text; + } + + /// + /// Starts one connection over the shared , mirroring the sample in + /// docs/mcp-transports.md — including passing no service provider to McpServer.Create. + /// + [TestMethod] + [Description("Regression guard: a reusable BuildMcpServerOptions() catalog is frozen once, with the modern discovery view, whatever era a client later negotiates. Execution must therefore keep that same view for deciding presence even when the request is an initialize-era one \u2014 keying it on the request instead leaves a legacy client holding a catalog entry it cannot call, which is the same advertised-but-unreachable defect the dynamic path had.")] + public async Task When_ALegacyClientCallsAGatedToolFromTheStaticCatalog_Then_TheCommandRuns() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new GatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartAsync(mcpOptions, BuildLegacyClientOptions(), cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain( + tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal), + because: "the static catalog is built with the modern view, so this tool is offered"); + + var result = await session.Client.CallToolAsync( + toolName: "gated", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + text.Should().Contain( + "roots-only", + because: "what this catalog advertised must be callable by the client it advertised it to"); + } + + /// A client that negotiates the initialize era and declares no capabilities. + private static McpClientOptions BuildLegacyClientOptions() => + new() { ProtocolVersion = McpProtocolRevisions.LastWithSessions }; + + private sealed class GatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("gated", () => "roots-only").ReadOnly(); + } + + private static Task StartAsync( + McpServerOptions mcpOptions, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) => + McpPipeSession.StartAsync( + async (io, token) => + { + var transport = new StreamServerTransport(io.InputStream, io.OutputStream, "shared-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, + cancellationToken); + + /// How long the caller in the cancellation guard waits before giving up. + private static readonly TimeSpan CallerPatience = TimeSpan.FromMilliseconds(200); + + /// + /// How long the client in that guard sits on roots/list before answering. Far longer than + /// , so which of the two the service honoured decides the outcome + /// rather than a race: waiting on the caller's token cancels, waiting on the fetch resolves. + /// It cannot simply never answer — the client dispatches server requests on its read loop, so a + /// blocked roots handler would also stop it reading the tool response. + /// + private static readonly TimeSpan ClientSilence = TimeSpan.FromSeconds(5); + + /// + /// The bound the caller must be released within. Generous against and + /// still far under , so it pins that the release came from the caller's + /// own token and not from the fetch finishing. + /// + private static readonly TimeSpan ReleasedWithin = TimeSpan.FromSeconds(2.5); + + /// How long the abandoned roots/list runs on after its only waiter has left. + private static readonly TimeSpan AbandonedFetchDelay = TimeSpan.FromMilliseconds(600); + + /// Time allowed for that answer to reach the server and fail while being mapped. + private static readonly TimeSpan FaultSettlingDelay = TimeSpan.FromMilliseconds(400); + + // Roots and sampling are deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported + // by Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + /// A client that declares roots and takes to answer. + private static McpClientOptions BuildSlowRootsClientOptions() => new() + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = async (_, token) => + { + await Task.Delay(ClientSilence, token).ConfigureAwait(false); + return new ListRootsResult { Roots = [new Root { Uri = "file:///slow", Name = "slow" }] }; + }, + }, + }; + + private static McpClientOptions BuildRootsClientOptions(string rootUri, Action? onRequest = null) => new() + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => + { + onRequest?.Invoke(); + return ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = rootUri, Name = rootUri }], + }); + }, + }, + }; + + /// + /// A client whose first roots/list outlives its waiter and then answers unusably, and whose + /// next one answers normally. + /// + private static McpClientOptions BuildAbandonedThenRecoveringRootsClientOptions( + TaskCompletionSource firstAnswered) + { + var calls = 0; + return new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = async (_, token) => + { + if (Interlocked.Increment(ref calls) > 1) + { + return new ListRootsResult + { + Roots = [new Root { Uri = "file:///recovered", Name = "recovered" }], + }; + } + + await Task.Delay(AbandonedFetchDelay, token).ConfigureAwait(false); + firstAnswered.TrySetResult(); + return new ListRootsResult + { + Roots = [new Root { Uri = "http://", Name = "unparseable" }], + }; + }, + }, + }; + } + + /// A client that fails the first roots/list and answers every later one. + private static McpClientOptions BuildFlakyRootsClientOptions() + { + var calls = 0; + return new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + // The first answer carries an unparseable URI, so the failure happens server-side while + // mapping the result — a real fetch failure. A handler that throws instead would escape + // the client's own message loop rather than failing the request on the wire. + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = Interlocked.Increment(ref calls) == 1 + ? [new Root { Uri = "http://", Name = "unparseable" }] + : [new Root { Uri = "file:///ga", Name = "ga" }], + }), + }, + }; + } + + private static McpClientOptions BuildSamplingClientOptions() => new() + { + Capabilities = new ClientCapabilities { Sampling = new SamplingCapability() }, + Handlers = new McpClientHandlers + { + SamplingHandler = static (request, _, _) => ValueTask.FromResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "ga" }], + Model = "test-model", + }), + }, + }; +#pragma warning restore MCP9005 +} diff --git a/src/Repl.McpTests/Given_McpSubscriptions.cs b/src/Repl.McpTests/Given_McpSubscriptions.cs new file mode 100644 index 00000000..f8d8173f --- /dev/null +++ b/src/Repl.McpTests/Given_McpSubscriptions.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +namespace Repl.McpTests; + +/// +/// Covers subscriptions/listen (SEP-2575) delivery over the in-process stream transport that +/// stands in for stdio, and the SDK behaviour Repl's discovery signal depends on. +/// +[TestClass] +public sealed class Given_McpSubscriptions +{ + [TestMethod] + [Description("Pins the undocumented SDK behaviour the discovery signal rests on: clearing an already-empty primitive collection must still raise Changed. McpServerHandler uses empty collections as pure list-changed signals, so if a future SDK turns Clear() into a no-op when the collection is empty, discovery notifications would silently stop; this test fails loudly instead.")] + public void When_ClearingAnEmptyCollection_Then_ChangedStillFires() + { + var resources = new McpServerResourceCollection(); + var tools = new McpServerPrimitiveCollection(); + var resourceSignals = 0; + var toolSignals = 0; + resources.Changed += (_, _) => resourceSignals++; + tools.Changed += (_, _) => toolSignals++; + + resources.Clear(); + tools.Clear(); + + resourceSignals.Should().Be(1); + toolSignals.Should().Be(1); + resources.Count.Should().Be(0, because: "the signal must not mutate anything a client could observe"); + tools.Count.Should().Be(0, because: "the signal must not mutate anything a client could observe"); + } + + [TestMethod] + [Description("Guards backward compatibility while the modern path is filtered: an initialize-era client that pins 2025-11-25 and opens NO subscription must still receive tools/list_changed as an unsolicited session-wide broadcast. Delegating fan-out to the SDK must not cost existing hosts their discovery notifications — the server stays multi-revision and the SDK picks the delivery mode per client.")] + public async Task When_LegacyClientNeverSubscribes_Then_ListChangedIsStillBroadcast() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("alpha", () => "a"), + configureOptions: null, + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var toolsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var registration = Capture(fixture, NotificationMethods.ToolListChangedNotification, toolsChanged); + await using var registrationScope = registration.ConfigureAwait(false); + + fixture.App.Map("late", () => "l"); + fixture.App.Core.InvalidateRouting(); + + await toolsChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + [TestMethod] + [Description("Confirms subscriptions/listen reaches a stream-transport (stdio-shaped) server and that the SDK's built-in handler acknowledges the filters it grants. Repl delegates list-changed fan-out to that pipeline, so this is the precondition the delegation rests on.")] + public async Task When_ClientOpensSubscriptionsListen_Then_ServerAcknowledges() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map("alpha", () => "a")) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var acknowledged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var registration = Capture(fixture, NotificationMethods.SubscriptionsAcknowledgedNotification, acknowledged); + await using var registrationScope = registration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = OpenListenAsync( + fixture, + new SubscriptionsListenNotifications { ToolsListChanged = true }, + listenCts.Token); + + var notification = await acknowledged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var granted = notification.Params + .Deserialize(McpJsonUtilities.DefaultOptions); + granted.Should().NotBeNull(); + granted!.Notifications.ToolsListChanged.Should().BeTrue(); + + await CloseListenAsync(listenTask, listenCts).ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards SEP-2575 delivery filtering: a 2026-07-28 client that subscribes to prompts/list_changed only must NOT receive tools/list_changed, and the notification it does receive must carry its listen request id. A server that sends */list_changed itself has no access to the subscription registry, so it delivers every type to every client, untagged.")] + public async Task When_ClientSubscribesToPromptsOnly_Then_ToolListChangedIsNotDelivered() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map("alpha", () => "a")) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var toolsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var promptsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var toolsRegistration = Capture(fixture, NotificationMethods.ToolListChangedNotification, toolsChanged); + await using var toolsScope = toolsRegistration.ConfigureAwait(false); + var promptsRegistration = Capture(fixture, NotificationMethods.PromptListChangedNotification, promptsChanged); + await using var promptsScope = promptsRegistration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = OpenListenAsync( + fixture, + new SubscriptionsListenNotifications { PromptsListChanged = true }, + listenCts.Token); + + fixture.App.Map("late", () => "l"); + fixture.App.Core.InvalidateRouting(); + + // Discovery signals fire tools-then-resources-then-prompts, so observing the prompts + // notification proves the tools one has already had its chance. + var prompts = await promptsChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // Render to a string rather than asserting on the JsonNode: NotBeNull on a node reached via + // ?. is vacuous (the null-conditional result satisfies it even when the payload is absent), + // and the listen request id is a JSON-RPC id, so it may be a number as well as a string. + var subscriptionId = prompts.Params?["_meta"]?[MetaKeys.SubscriptionId]?.ToJsonString(); + subscriptionId.Should().NotBeNullOrEmpty( + because: "SEP-2575 requires every subscription notification to carry its listen request id"); + toolsChanged.Task.IsCompleted.Should().BeFalse( + because: "the client never subscribed to tools/list_changed"); + + await CloseListenAsync(listenTask, listenCts).ConfigureAwait(false); + } + + private static IAsyncDisposable Capture( + McpTestFixture fixture, + string method, + TaskCompletionSource received) => + fixture.Client.RegisterNotificationHandler( + method, + (notification, _) => + { + received.TrySetResult(notification); + return ValueTask.CompletedTask; + }); + + /// + /// subscriptions/listen is a long-lived request: the response is held open for the + /// subscription's lifetime, so it must not be awaited until the stream is cancelled. + /// + private static Task OpenListenAsync( + McpTestFixture fixture, + SubscriptionsListenNotifications filters, + CancellationToken cancellationToken) => + fixture.Client.SendRequestAsync( + RequestMethods.SubscriptionsListen, + new SubscriptionsListenRequestParams { Notifications = filters }, + cancellationToken: cancellationToken) + .AsTask(); + + private static async Task CloseListenAsync(Task listenTask, CancellationTokenSource listenCts) + { + await listenCts.CancelAsync().ConfigureAwait(false); + try + { + // The listen request is deliberately started by the caller and awaited only once its + // stream has been cancelled. MSTest runs without a synchronization context, so the + // deadlock VSTHRD003 guards against cannot arise here. +#pragma warning disable VSTHRD003 + await listenTask.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected: the listen stream ends on cancellation. + } + } +} diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 4a286695..0dc32184 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -633,7 +633,8 @@ public async Task When_StringToolValueNamesResponseFile_Then_ProgrammaticInvocat }) .WithOption("hidden", static option => option.Hidden()); await using var services = new ServiceCollection().BuildServiceProvider(); - var adapter = new McpToolAdapter(app.Core, new ReplMcpServerOptions(), services); + var adapter = new McpToolAdapter( + app.Core, new ReplMcpServerOptions(), services, new McpRequestServerAccessor()); adapter.RegisterRoute( "deploy", new ReplDocCommand( diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 0a8bd859..1283b509 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1,15 +1,29 @@ +using Repl.Parameters; +using Microsoft.Extensions.DependencyInjection; +using System.IO.Pipelines; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Globalization; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; using Repl.Interaction; +using Repl.Mcp; + +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 namespace Repl.McpTests; [TestClass] public sealed class Given_McpUserFeedback { + private const int RawRequestId = 1; + [TestMethod] [Description("Interaction-based user feedback is routed as MCP logging notifications with the expected severities.")] public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() @@ -19,7 +33,7 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() NotificationCaptureState.Current = captureState; try { - await using var fixture = await CreateFeedbackFixtureAsync(clientOptions: CreateClientOptions()).ConfigureAwait(false); + await using var fixture = await CreateFeedbackFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); var result = await fixture.Client.CallToolAsync( toolName: "feedback", @@ -44,7 +58,7 @@ public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMe NotificationCaptureState.Current = captureState; try { - await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + await using var fixture = await CreateStructuredProgressFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); var result = await fixture.Client.CallToolAsync( toolName: "feedback_progress", @@ -63,6 +77,358 @@ await WaitForConditionAsync(() => } } + [TestMethod] + [Description("Guards the 2026-07-28 rule that a server MUST NOT emit notifications/message for a request that declared no log level (SEP-2575) — and guards against that rule silently swallowing user feedback: the notice, warning and problem the command reported must instead ride back in the tool result, so no host loses them.")] + public async Task When_RequestDeclaresNoLogLevel_Then_FeedbackRidesInTheToolResultInstead() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var result = await fixture.Client.CallToolAsync( + toolName: "feedback", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + // Give a (forbidden) notification time to arrive before asserting that none did. + await WaitForConditionAsync(() => notifications.Count > 0, timeoutMs: 500).ConfigureAwait(false); + + notifications.Should().BeEmpty( + because: "the request declared no log level, so the server must not emit message notifications"); + result.Content[0].Should().BeOfType( + because: "the documented guarantee is about Content[0], not about the first text block"); + var blocks = result.Content.OfType().ToArray(); + blocks[0].Text.Should().Contain( + "done", + because: "the command's own payload stays the first block, which is what makes the " + + "appended messages non-breaking for a client reading Content[0]"); + blocks[0].Text.Should().NotContain("Connected", because: "messages are appended, never prepended"); + + var text = string.Join('\n', blocks.Select(block => block.Text)); + text.Should().Contain("Connected"); + text.Should().Contain("Token expires soon"); + text.Should().Contain("Sync failed"); + result.IsError.Should().BeFalse(); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + [TestMethod] + [Description("Guards the half of the 2026-07-28 logging rule that had no coverage at all: a request that DOES declare _meta/io.modelcontextprotocol/logLevel must receive notifications, filtered at that level. The SDK's own client cannot express this - it replaces a caller's _meta with its own keys - so the request goes out as a raw JSON-RPC frame. Without it, a regression resolving no threshold on the modern path would disable the feature silently and leave every other test green.")] + public async Task When_ARequestDeclaresALogLevel_Then_MessagesAtOrAboveItAreNotified() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("feedback", static async (IMcpFeedback feedback, CancellationToken ct) => + { + await feedback.SendMessageAsync(McpMessageLevel.Info, "below-threshold", ct).ConfigureAwait(false); + await feedback.SendMessageAsync(McpMessageLevel.Error, "above-threshold", ct).ConfigureAwait(false); + return "done"; + }); + + // Assembled as a node rather than written as a literal: the whole point of this test is the + // _meta the SDK client overwrites, and hand-escaped JSON is how a typo becomes a silent pass. + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = RawRequestId, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "feedback", + ["arguments"] = new JsonObject(), + ["_meta"] = new JsonObject + { + ["io.modelcontextprotocol/protocolVersion"] = McpProtocolRevisions.Sessionless, + // Required by the revision: the server rejects a request without it. + ["io.modelcontextprotocol/clientCapabilities"] = new JsonObject(), + ["io.modelcontextprotocol/logLevel"] = "warning", + }, + }, + }; + + var frames = await ExchangeRawFrameAsync(app.BuildMcpServerOptions(), request).ConfigureAwait(false); + + var notified = frames + .Where(static frame => string.Equals( + frame["method"]?.GetValue(), + NotificationMethods.LoggingMessageNotification, + StringComparison.Ordinal)) + .Select(static frame => frame["params"]?["data"]?.GetValue()) + .ToArray(); + + notified.Should().Contain( + "above-threshold", + because: "a request that declared a level must receive messages at or above it"); + notified.Should().NotContain( + "below-threshold", + because: "the declared level is a threshold, not merely permission to send"); + } + + /// + /// Hosts on a pipe pair, writes one raw JSON-RPC frame, and returns + /// everything the server wrote up to and including its response. + /// + private static async Task> ExchangeRawFrameAsync( + McpServerOptions mcpOptions, + JsonObject request) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var transport = new StreamServerTransport( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream(), + serverName: "raw-meta-server"); + var server = McpServer.Create(transport, mcpOptions); + var serverTask = server.RunAsync(cts.Token); + try + { + var payload = Encoding.UTF8.GetBytes(request.ToJsonString() + "\n"); + await clientToServer.Writer.WriteAsync(payload, cts.Token).ConfigureAwait(false); + await clientToServer.Writer.FlushAsync(cts.Token).ConfigureAwait(false); + + return await ReadFramesUntilResponseAsync(serverToClient.Reader, cts.Token).ConfigureAwait(false); + } + finally + { + await cts.CancelAsync().ConfigureAwait(false); + await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + try + { + await serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: RunAsync ends on cancellation. + } + + await server.DisposeAsync().ConfigureAwait(false); + await transport.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task> ReadFramesUntilResponseAsync( + PipeReader reader, + CancellationToken cancellationToken) + { + var frames = new List(); + using var lines = new StreamReader(reader.AsStream(), Encoding.UTF8); + while (await lines.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + if (JsonNode.Parse(line) is not JsonObject frame) + { + continue; + } + + frames.Add(frame); + if (frame["id"] is JsonValue id && id.TryGetValue(out int value) && value == RawRequestId) + { + break; + } + } + + return frames; + } + + [TestMethod] + [DataRow(McpMessageLevel.Debug, LoggingLevel.Debug, DisplayName = "Debug")] + [DataRow(McpMessageLevel.Info, LoggingLevel.Info, DisplayName = "Info")] + [DataRow(McpMessageLevel.Notice, LoggingLevel.Notice, DisplayName = "Notice")] + [DataRow(McpMessageLevel.Warning, LoggingLevel.Warning, DisplayName = "Warning")] + [DataRow(McpMessageLevel.Error, LoggingLevel.Error, DisplayName = "Error")] + [DataRow(McpMessageLevel.Critical, LoggingLevel.Critical, DisplayName = "Critical")] + [DataRow(McpMessageLevel.Alert, LoggingLevel.Alert, DisplayName = "Alert")] + [DataRow(McpMessageLevel.Emergency, LoggingLevel.Emergency, DisplayName = "Emergency")] + [Description("Guards the equivalence the upgrade note in docs/mcp-reference.md promises consumers: McpMessageLevel has the same members and the same numeric values as the SDK's LoggingLevel, so swapping one for the other in a call is mechanical. Repl no longer leans on that agreement internally - the conversion is a switch - but the documentation still tells consumers it holds, and a silent SDK renumbering would make that advice wrong.")] + public void When_AMessageLevelIsComparedToTheProtocolLevel_Then_NameAndValueAgree( + McpMessageLevel level, + LoggingLevel protocolLevel) + { + ((int)level).Should().Be((int)protocolLevel); + level.ToString().Should().Be(protocolLevel.ToString()); + } + + [TestMethod] + [Description("Guards the other half of the same promise: the upgrade note tells consumers the two enums have the same MEMBERS, which eight known pairs cannot pin — an SDK addition would leave them all green while making the advice wrong, and would also reach FromProtocol's throw at runtime.")] + public void When_TheProtocolLevelsAreEnumerated_Then_TheyMatchMcpMessageLevel() + { + Enum.GetNames().Should().BeEquivalentTo(Enum.GetNames()); + } + + [TestMethod] + [Description("Guards severity filtering against the level the client asked for: after logging/setLevel(Error) the notice and warning a command reports must not be delivered, while the problem must. Emitting everything regardless of the requested threshold floods hosts that deliberately asked for errors only.")] + public async Task When_ClientRequestsErrorLevel_Then_LowerSeveritiesAreNotNotified() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); + await fixture.Client.SetLoggingLevelAsync(LoggingLevel.Error).ConfigureAwait(false); + + await fixture.Client.CallToolAsync( + toolName: "feedback", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + await WaitForConditionAsync(() => notifications.Count >= 1).ConfigureAwait(false); + + notifications.Should().OnlyContain(entry => entry.Level == LoggingLevel.Error); + notifications.Should().ContainSingle(entry => + entry.Data.Contains("Sync failed", StringComparison.Ordinal)); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + /// + /// A client pinned to the last revision on which message notifications can be requested at all. + /// + /// + /// On 2026-07-28 the SDK's client cannot ask for a log level: it rejects + /// logging/setLevel for that revision, exposes no option for the level, and replaces a + /// caller's _meta with its own keys (protocol version, client info, capabilities). Tests + /// that assert notification DELIVERY therefore have to pin the initialize-era revision. The modern + /// path is covered by + /// . + /// + private static McpClientOptions LegacyClientOptions() + { + var options = CreateClientOptions(); + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + return options; + } + + [TestMethod] + [Description("Regression guard: a prompt must carry the same buffered feedback a tool does. On 2026-07-28 a request that declared no log level receives no message notifications, so feedback the command reported survives only inside the result — and prompts/get kept just the first content block, making it the one path that silently discarded it.")] + public async Task When_APromptDeclaresNoLogLevel_Then_FeedbackRidesInThePromptResultInstead() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync( + app => app.Map( + "review", + static async (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteNoticeAsync( + text: "review-notice", + cancellationToken: cancellationToken).ConfigureAwait(false); + return "payload"; + }).AsPrompt(), + CreateClientOptions()).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var result = await fixture.Client.GetPromptAsync("review").ConfigureAwait(false); + var texts = result.Messages + .Select(message => message.Content) + .OfType() + .Select(block => block.Text) + .ToArray(); + + notifications.Should().BeEmpty( + because: "the request declared no log level, so the server must not emit message notifications"); + texts[0].Should().Contain( + "payload", + because: "the command's own payload stays the first message, as it stays the first block on the tool path"); + string.Join(' ', texts).Should().Contain( + "review-notice", + because: "feedback the client could not receive as a notification must survive somewhere"); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + [TestMethod] + [Description("Regression guard: a prompt that FAILS must still carry its buffered feedback. The error branch surfaces an McpException built from the content blocks, and building it from the first block alone discarded exactly the messages the no-log-level path exists to preserve — at the moment they matter most, since a notice explaining what went wrong is worth more on a failure than on a success.")] + public async Task When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync( + app => app.Map( + "review", + static async Task (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteNoticeAsync( + text: "review-notice", + cancellationToken: cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("review-failed"); + }).AsPrompt(), + CreateClientOptions()).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var act = async () => await fixture.Client.GetPromptAsync("review").ConfigureAwait(false); + + (await act.Should().ThrowAsync().ConfigureAwait(false)) + .Which.Message.Should().Contain( + "review-notice", + because: "feedback the client could not receive as a notification must survive a failure too"); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + [TestMethod] + [Description("Regression guard: an ordinary resource read that fails must carry the feedback its command emitted. On success a resource body has to match the advertised MIME type, so trailing blocks are dropped there on purpose — but a failure has no body at all, and the resource path built its error from the command output alone, discarding the notices that explain it.")] + public async Task When_AFailingResourceReadDeclaresNoLogLevel_Then_FeedbackRidesInTheError() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync( + app => app.Map( + "report", + static async Task (IReplInteractionChannel interaction, CancellationToken cancellationToken) => + { + await interaction.WriteNoticeAsync( + text: "report-notice", + cancellationToken: cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("report-failed"); + }).ReadOnly().AsResource(), + CreateClientOptions()).ConfigureAwait(false); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var resources = await fixture.Client.ListResourcesAsync().ConfigureAwait(false); + var uri = resources.Select(static resource => resource.Uri).Single(); + + var act = async () => await fixture.Client.ReadResourceAsync(uri).ConfigureAwait(false); + + (await act.Should().ThrowAsync().ConfigureAwait(false)) + .Which.Message.Should().Contain( + "report-notice", + because: "a failed read has nowhere else to carry what the command reported"); + } + finally + { + NotificationCaptureState.Current = null; + } + } + private static async Task CreateFeedbackFixtureAsync( McpClientOptions clientOptions) => await CreateFeedbackFixtureAsync( @@ -269,4 +635,317 @@ private sealed record NotificationCaptureState( { public static NotificationCaptureState? Current { get; set; } } + + [TestMethod] + [Description("Regression guard: a dependency that throws while being activated must not reach the client. Its exception escaped application code during binding \u2014 it can name a path, a connection string or provider internals \u2014 and it is classified as a binding failure, the same as a diagnostic the binder wrote itself, so the kind alone cannot tell them apart. The binder marks activation failures for exactly this.")] + public async Task When_ADependencyFactoryThrows_Then_ItsMessageIsWithheld() + { + var session = await McpTestFixture.CreateAsync( + configure: app => app.Map("probe", (IThrowingDependency dependency) => dependency.ToString() ?? "ok"), + configureOptions: null, + clientOptions: null, + configureServices: services => services.AddSingleton( + implementationFactory: static _ => throw new IOException("review-marker-detail"))).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "probe", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().NotContain( + "review-marker-detail", + because: "what a container factory raises is not the framework speaking to this caller"); + text.Should().NotContain( + "IThrowingDependency", + because: "the wrapper names the service and the parameter, which is the second half of " + + "what this withholds — the binder marks the failure, and MCP replaces even that"); + } + } + + [TestMethod] + [Description("Pins the other side: a diagnostic the binder wrote itself must still reach the client. It names what could not be supplied and is what an agent reads to correct its next call \u2014 withholding every binding failure to close the activation leak would trade a disclosure for silence on a far busier path.")] + public async Task When_ARequiredDependencyIsMissing_Then_TheBindersDiagnosticReaches() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("probe", ([FromServices] IThrowingDependency dependency) => "ok")).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "probe", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().Contain( + "dependency", + because: "the binder named the parameter it could not supply, and that is actionable"); + } + } + + [TestMethod] + [Description("Regression guard: the interaction channel raises McpInteractionException to tell the caller which answer it failed to supply, naming the tool argument to send next time. That is addressed to the client, so the withholding rule must not swallow it — doing so would leave the default PrefillThenFail mode unable to say what it needs, and the caller with an exit code and no way forward.")] + public async Task When_APromptHasNoPrefill_Then_TheInstructionReachesTheClient() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("ask", async (IReplInteractionChannel channel) => + await channel.AskConfirmationAsync(name: "city", prompt: "Proceed?").ConfigureAwait(false) + ? "yes" + : "no")).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "ask", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().Contain( + "answer.city", + because: "the exception exists to name the argument the caller must send, and it is the " + + "only thing that turns a refusal into something an agent can act on"); + } + } + + [TestMethod] + [Description("Regression guard: an options-group property setter is application code the binder invokes, like a service factory, and an exception escaping it must not reach the client. It is classified as a binding failure with no marker, so without provenance it passes for a diagnostic the binder wrote itself — and a setter can expose the same paths and application state a factory can.")] + public async Task When_AnOptionsGroupSetterThrows_Then_ItsMessageIsWithheld() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("configure", (ThrowingOptions options) => options.Label ?? "ok")).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "configure", + arguments: new Dictionary(StringComparer.Ordinal) { ["label"] = "x" }) + .ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().NotContain( + "setter-marker-detail", + because: "a property setter is application code, and what escapes it is not addressed to this caller"); + } + } + + [ReplOptionsGroup] + public sealed class ThrowingOptions + { + private string? _label; + + public string? Label + { + get => _label; + set + { + _label = value; + throw new InvalidOperationException("setter-marker-detail"); + } + } + } + + [TestMethod] + [Description("Regression guard: a dependency factory that runs its own budget and gives up has failed like any other, and its message must be withheld too. Excluding cancellation by the exception's type left it unmarked, so it passed for a diagnostic the binder wrote — cancellation is told apart by who asked for it, and the caller here never withdrew.")] + public async Task When_ADependencyFactoryCancelsItself_Then_ItsMessageIsWithheld() + { + var session = await McpTestFixture.CreateAsync( + configure: app => app.Map("probe", (IThrowingDependency dependency) => dependency.ToString() ?? "ok"), + configureOptions: null, + clientOptions: null, + configureServices: services => services.AddSingleton( + implementationFactory: static _ => throw new OperationCanceledException("cancel-marker-detail"))) + .ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "probe", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + text.Should().NotContain( + "cancel-marker-detail", + because: "the caller never withdrew, so this is the factory failing rather than a withdrawal"); + } + } + + [TestMethod] + [Description("Regression guard: an explicitly registered prompt handler that reports feedback must not lose it. On 2026-07-28 a request that declared no log level receives no message notifications, so the buffer is the only carrier \u2014 and this primitive opened none, because the SDK invokes its handler directly rather than through the adapter that opens one for every command-backed path.")] + public async Task When_AnExplicitPromptReports_Then_TheFeedbackRidesInTheResult() + { + var session = await McpTestFixture.CreateAsync( + _ => { }, + options => options.Prompt( + "brief", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "prompt-notice", + cancellationToken).ConfigureAwait(false); + return "drafted"; + })).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var result = await session.Client.GetPromptAsync( + "brief", + arguments: null, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); + + text.Should().Contain( + "prompt-notice", + because: "a modern request that declared no log level has nowhere else to receive it"); + } + } + + [TestMethod] + [Description("Regression guard: an explicitly registered prompt handler must see the connection\u0027s native roots. Every other execution path primes them before the handler runs, so a handler reading Current here would see an empty list and take it for a client that declared no workspace \u2014 a difference it has no way to detect.")] + public async Task When_AnExplicitPromptReadsRoots_Then_TheyAreResolved() + { + // Roots are deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but still supported by + // Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities { Roots = new RootsCapability { ListChanged = true } }, + Handlers = new McpClientHandlers + { + RootsHandler = static (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = "file:///C:/workspace", Name = "workspace" }], + }), + }, + }; +#pragma warning restore MCP9005 + + var session = await McpTestFixture.CreateAsync( + _ => { }, + options => options.Prompt( + "where", + static (IMcpClientRoots roots) => + string.Join(',', roots.Current.Select(static r => r.Uri.ToString()))), + clientOptions).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.GetPromptAsync( + "where", + arguments: null, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); + + text.Should().Contain( + "file:///C:/workspace", + because: "Current promises the connection\u0027s effective roots on every execution path"); + } + } + + /// A dependency no test registers successfully; only its resolution path matters. + public interface IThrowingDependency; + + [TestMethod] + [Description("Regression guard: an exception the handler never caught must not reach the client verbatim. The framework renders that message for an operator at a console, and it routinely carries a path, a parameter and its CLR type, or a connection string \u2014 over MCP the reader is a remote client instead. App-authored feedback still travels, because the app wrote it for that reader.")] + public async Task When_AResourceHandlerThrows_Then_TheExceptionTextIsWithheld() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("leaky", async (IMcpFeedback feedback, CancellationToken ct) => + { + await feedback.SendMessageAsync(McpMessageLevel.Warning, "render-notice", ct).ConfigureAwait(false); + throw new InvalidOperationException("secret-internal-detail"); + }) + .ReadOnly() + .AsResource()).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var act = async () => await session.Client.ReadResourceAsync("repl://leaky").ConfigureAwait(false); + + var message = (await act.Should().ThrowAsync().ConfigureAwait(false)).Which.Message; + + message.Should().NotContain( + "secret-internal-detail", + because: "the framework rendered that text for an operator, not for a remote caller"); + message.Should().Contain( + "render-notice", + because: "the app authored that message for the client and a failed read has no body for it"); + } + } + + [TestMethod] + [Description("Regression guard: the same withholding on the tool path, which is the one clients actually call. A tool result carries its text as content rather than as an exception, so the SDK never sanitizes it and this is the only thing that does.")] + public async Task When_AToolHandlerThrows_Then_TheExceptionTextIsWithheld() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("leaky", string () => + throw new InvalidOperationException("secret-internal-detail"))).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "leaky", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + '\n', + result.Content.OfType().Select(static block => block.Text)); + + result.IsError.Should().BeTrue(); + text.Should().NotContain( + "secret-internal-detail", + because: "a thrown handler is not the app speaking to the client"); + } + } + + [TestMethod] + [Description("Pins the other side of the same rule: a failure the handler CHOSE to return is the app speaking, and must reach the client word for word. Withholding it would turn every actionable error into a bare exit code, which is the regression this guard exists to catch.")] + public async Task When_AHandlerReturnsAFailure_Then_ItsOwnTextStillReaches() + { + var session = await McpTestFixture.CreateAsync( + app => app.Map("refuse", () => Results.Error("bad-env", "environment must be staging or production"))).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + var result = await session.Client.CallToolAsync( + toolName: "refuse", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + var text = string.Join( + '\n', + result.Content.OfType().Select(static block => block.Text)); + + text.Should().Contain( + "environment must be staging or production", + because: "the handler authored that for whoever called it, and withholding it helps nobody"); + } + } } diff --git a/src/Repl.McpTests/McpPipeSession.cs b/src/Repl.McpTests/McpPipeSession.cs new file mode 100644 index 00000000..c0a33209 --- /dev/null +++ b/src/Repl.McpTests/McpPipeSession.cs @@ -0,0 +1,118 @@ +using System.IO.Pipelines; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Repl.McpTests; + +/// +/// One MCP client connected to an existing over in-process pipes. +/// +/// +/// Several sessions can share one handler, which is what the concurrent-session regressions need. +/// This type owns the transport pair, the cancellation source, and — crucially — the +/// RunAsync task: awaits it and lets any fault surface. A discarded +/// task would let a server that throws during teardown leave every concurrency test green. +/// +internal sealed class McpPipeSession : IAsyncDisposable +{ + private readonly CancellationTokenSource _cts; + private readonly Pipe _clientToServer; + private readonly Pipe _serverToClient; + private readonly Task _serverTask; + + private McpPipeSession( + McpClient client, + Task serverTask, + CancellationTokenSource cts, + Pipe clientToServer, + Pipe serverToClient) + { + Client = client; + _serverTask = serverTask; + _cts = cts; + _clientToServer = clientToServer; + _serverToClient = serverToClient; + } + + public McpClient Client { get; } + + /// + /// Starts a session by handing the server side of a fresh pipe pair. + /// + /// + /// The handshake races the server task so a server that fails while starting surfaces its own + /// exception instead of an initialize timeout carrying the wrong one. + /// + public static async Task StartAsync( + Func startServer, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var io = new McpTestFixture.PipeIoContext( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()); + var serverTask = startServer(io, cts.Token); + + var clientTransport = new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()); + + try + { + var clientTask = McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cts.Token); + if (ReferenceEquals(await Task.WhenAny(serverTask, clientTask).ConfigureAwait(false), serverTask)) + { + // Rethrows a start failure; a clean early exit means the handshake never completes. + await serverTask.ConfigureAwait(false); + + throw new InvalidOperationException( + "The MCP server stopped before the client completed its handshake."); + } + + var client = await clientTask.ConfigureAwait(false); + + return new McpPipeSession(client, serverTask, cts, clientToServer, serverToClient); + } + catch + { + await cts.CancelAsync().ConfigureAwait(false); + await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + cts.Dispose(); + throw; + } + } + + /// + /// Closes the session and asserts the server terminated cleanly. + /// + /// + /// Only cancellation is an accepted outcome. A fault propagates, and so does a timeout: a server + /// that never shuts down is a defect, not noise to be swallowed. + /// + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync().ConfigureAwait(false); + await _cts.CancelAsync().ConfigureAwait(false); + + await _clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await _serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + + try + { + await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: the server's RunAsync ends on cancellation. + } + finally + { + _cts.Dispose(); + } + } +} diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 04279eee..0e37690f 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -1,4 +1,3 @@ -using System.IO.Pipelines; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -14,30 +13,17 @@ namespace Repl.McpTests; /// internal sealed class McpTestFixture : IAsyncDisposable { - private readonly CancellationTokenSource _cts; - private readonly Pipe _clientToServer; - private readonly Pipe _serverToClient; - private readonly Task _serverTask; + private readonly McpPipeSession _session; private readonly ReplApp _app; - private McpTestFixture( - ReplApp app, - McpClient client, - Task serverTask, - CancellationTokenSource cts, - Pipe clientToServer, - Pipe serverToClient) + private McpTestFixture(ReplApp app, McpPipeSession session) { _app = app; - Client = client; - _serverTask = serverTask; - _cts = cts; - _clientToServer = clientToServer; - _serverToClient = serverToClient; + _session = session; } public ReplApp App => _app; - public McpClient Client { get; } + public McpClient Client => _session.Client; public static Task CreateAsync(Action configure) => CreateAsync(configure, configureOptions: null, clientOptions: null); @@ -61,97 +47,34 @@ public static async Task CreateAsync( var options = new ReplMcpServerOptions(); configureOptions?.Invoke(options); + options.TransportFactory ??= PipeTransportFactory; - var serviceProvider = app.Services; - var handler = new McpServerHandler(app.Core, options, serviceProvider); - - var clientToServer = new Pipe(); - var serverToClient = new Pipe(); - var cts = new CancellationTokenSource(); - - var inputStream = clientToServer.Reader.AsStream(); - var outputStream = serverToClient.Writer.AsStream(); - var ioContext = new PipeIoContext(inputStream, outputStream); - if (options.TransportFactory is null) - { - options.TransportFactory = static (serverName, io) => new StreamServerTransport( - ((PipeIoContext)io).InputStream, - ((PipeIoContext)io).OutputStream, - serverName); - } - var serverTask = handler.RunAsync(ioContext, cts.Token); - - var clientTransport = new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream()); - - try - { - // Race the handshake against the server. Awaiting only the client means a server that - // fails while starting is observable solely as an initialize timeout carrying the wrong - // exception — which is what once pushed production code into throwing synchronously - // just to stay testable. - var clientTask = McpClient.CreateAsync(clientTransport, clientOptions); - if (ReferenceEquals(await Task.WhenAny(serverTask, clientTask).ConfigureAwait(false), serverTask)) - { - // Rethrows a start failure; a clean early exit means the handshake never completes. - await serverTask.ConfigureAwait(false); - - throw new InvalidOperationException( - "The MCP server stopped before the client completed its handshake."); - } - - var client = await clientTask.ConfigureAwait(false); - - return new McpTestFixture(app, client, serverTask, cts, clientToServer, serverToClient); - } - catch - { - await AbandonAsync(cts, clientToServer, serverToClient).ConfigureAwait(false); - throw; - } - } + var handler = new McpServerHandler(app.Core, options, app.Services); - /// - /// Releases what - /// allocated when construction fails before the fixture takes ownership. - /// - private static async Task AbandonAsync( - CancellationTokenSource cts, - Pipe clientToServer, - Pipe serverToClient) - { - await cts.CancelAsync().ConfigureAwait(false); - await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); - await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); - cts.Dispose(); + var session = await McpPipeSession + .StartAsync(handler.RunAsync, clientOptions, CancellationToken.None) + .ConfigureAwait(false); + + return new McpTestFixture(app, session); } - public async ValueTask DisposeAsync() + /// Builds the server transport over the pipe pair a supplies. + internal static Func PipeTransportFactory { get; } = + static (serverName, io) => new StreamServerTransport( + ((PipeIoContext)io).InputStream, + ((PipeIoContext)io).OutputStream, + serverName); + + public ValueTask DisposeAsync() => _session.DisposeAsync(); + + internal static IServiceProvider EmptyServices => EmptyServiceProvider.Instance; + + private sealed class EmptyServiceProvider : IServiceProvider { - await Client.DisposeAsync().ConfigureAwait(false); - await _cts.CancelAsync().ConfigureAwait(false); - - await _clientToServer.Writer.CompleteAsync().ConfigureAwait(false); - await _serverToClient.Writer.CompleteAsync().ConfigureAwait(false); - - try - { - await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected: server RunAsync cancelled during shutdown. - } - catch (TimeoutException) - { - // Server did not shut down within timeout — transport will be collected. - } - - _cts.Dispose(); + public static readonly EmptyServiceProvider Instance = new(); + public object? GetService(Type serviceType) => null; } - internal sealed class PipeIoContext(Stream inputStream, Stream outputStream) : IReplIoContext { public Stream InputStream => inputStream; From 7135ede25215415b0297aa46cff400747e9e4491 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 13:55:28 -0400 Subject: [PATCH 03/15] docs(mcp): describe the 2.x behaviour, and what it does not guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP pages described the 1.x line throughout. They now cover what the migration actually delivers, and — where it matters more — where the guarantees stop. A new conformance page states the position per revision: what `2026-07-28` changes, which guarantee Repl keeps, and the deliberate gaps, each naming the test that pins it. The invariance section names the whole frozen set rather than the capability services alone, and says plainly that a predicate injecting an application service of its own stays outside it — Repl cannot know which of your singletons a command mutates. Corrections to claims that were not true as written: an advertised capability-gated command is guaranteed reachable, and writing the error when the capability is missing is the author's job, not something the framework produces; feedback survives everywhere except a resource read that succeeds; `Current` falls back to soft roots while nothing native has resolved, and the two scopes differ; `*/list_changed` never fires on the reusable-options path. The module-presence page teaches sign-in-then-reveal as its motivating example. That flow works in the console and over the earlier revisions but no longer changes what a modern MCP client is offered, so it says so where the flow is taught rather than only in a note at the top. --- docs/for-coding-agents.md | 2 +- docs/mcp-advanced.md | 48 ++++++++++-- docs/mcp-agent-capabilities.md | 34 +++++++-- docs/mcp-conformance.md | 129 +++++++++++++++++++++++++++++++++ docs/mcp-overview.md | 2 +- docs/mcp-reference.md | 86 +++++++++++++++++++++- docs/mcp-transports.md | 46 ++++++++++-- docs/module-presence.md | 17 +++++ 8 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 docs/mcp-conformance.md diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 0e61fba8..3887440a 100644 --- a/docs/for-coding-agents.md +++ b/docs/for-coding-agents.md @@ -142,7 +142,7 @@ Use these annotations to help agents make safer decisions: | `.Destructive()` | May delete or mutate important state; ask for confirmation. | | `.Idempotent()` | Safe to retry. | | `.OpenWorld()` | Talks to external systems; expect latency and failures. | -| `.LongRunning()` | May take time; use call-now / poll-later patterns. | +| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | | `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. | diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index f82ebdd9..bfe1487a 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -20,6 +20,18 @@ If your tool list is static, stay with the default setup from [mcp-overview.md]( ## Client roots +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it +> **for existing hosts and applications only.** New applications should take the workspace as an +> **explicit command parameter**, or mint a handle from a setup command and pass it back — that is +> what SEP-2567 prescribes now that the protocol has no sessions to hang such state on. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. +> +> [Soft roots](#soft-roots-fallback) are **not** the modern answer: they are the same +> connection-scoped state by another name, and they are scoped to the process rather than the +> connection when a host reuses one `BuildMcpServerOptions()` result. Treat them as a legacy +> compatibility feature for clients that lack native roots. + A **root** is a URI the client declares as being in scope for the session — typically an opened project folder, a working directory, or a boundary for what the agent should inspect or modify. Roots give the server session-specific workspace context without inventing a custom protocol. When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`. @@ -39,10 +51,10 @@ app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) = | Member | Meaning | |---|---| | `IsSupported` | The connected client supports native MCP roots | -| `Current` | Current effective roots for the session | +| `Current` | Roots already resolved for the current scope — the session under `mcp serve`, this request on a reused `BuildMcpServerOptions()` result, where it is empty until `GetAsync` has been called. Under `mcp serve`, soft roots stand in while nothing native has been resolved, so an empty answer means the roots in force are empty rather than unresolved; call `GetAsync` when you need the failure itself | | `GetAsync()` | Refreshes native roots if supported | | `HasSoftRoots` | Fallback roots were initialized manually | -| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots for the current session | +| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots — per connection under `mcp serve`, per process when a host reuses one `BuildMcpServerOptions()` result | > **Why `IMcpClientRoots` is MCP-only:** Roots are session-scoped MCP data. They don't make sense as a generic `Repl.Core` concept for terminal or non-MCP execution. That's why the interface lives in `Repl.Mcp` and is injected only for MCP sessions. @@ -50,6 +62,12 @@ app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) = Because `IMcpClientRoots` is injectable, you can use it in command handlers and in module presence predicates. That lets you expose tools only when a certain MCP capability or session state is available. +> **On revision `2026-07-28` this stops varying by client.** Discovery there runs presence predicates +> against fixed answers — capability checks read as supported, soft roots as absent, the root list as +> empty — so whatever the predicate returns is what every client is offered. The predicate still runs +> normally on the earlier revisions and outside MCP. See +> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands). + ```csharp using Repl.Mcp; @@ -60,12 +78,14 @@ app.MapModule( > **How this works internally:** The MCP integration builds its documentation model and MCP surfaces using the current MCP session service provider, not just the app root service provider. This makes session-scoped services like `IMcpClientRoots` visible to module presence predicates, tool handlers, prompt handlers, and resource handlers. -Typical session-aware conditions: +Typical session-aware conditions, with what each becomes on `2026-07-28`: -- Roots are available -- Soft roots were initialized -- The current tenant or login is known -- A module should appear only for one agent session +| Condition | On `2026-07-28` | +| --- | --- | +| Roots are available | Always true, so the module is advertised to every client | +| Soft roots were initialized | Always false, so the module is advertised to none | +| The current tenant or login is known | Unchanged — application state, not a per-connection MCP answer | +| A module should appear only for one agent session | Not expressible: the advertised set must not vary per connection | ### MCP-only vs workspace-aware commands @@ -79,6 +99,9 @@ app.MapModule( Use when: the command helps an agent initialize MCP session state or depends directly on MCP capabilities. +This gate asks whether the service exists at all rather than what it answers, so it is unaffected by +the fixed answers above: it stays true inside MCP on every revision and false outside it. + **Pattern 2: Workspace-aware** — the command works both inside and outside MCP: ```csharp @@ -113,6 +136,11 @@ app.MapModule( new WorkspaceModule(), (IMcpClientRoots roots) => roots.IsSupported || roots.HasSoftRoots); +// On revision 2026-07-28 both predicates above resolve to constants during discovery: +// IsSupported answers true and HasSoftRoots answers false, so SoftRootsInitModule is +// advertised to no client and WorkspaceModule to every client. Map the bootstrap module +// unconditionally if you serve that revision — see docs/mcp-conformance.md. + sealed class SoftRootsInitModule : IReplModule { public void Map(IReplMap app) @@ -148,6 +176,12 @@ app.UseMcpServer(o => }); ``` +> **Initialize-era only.** On `2026-07-28` the bootstrap does not run and the first `tools/list` +> already returns the real catalog: that revision forbids the advertised set from changing as a side +> effect of another request on the connection, which is exactly what the two-step bootstrap does. The +> shim exists for clients that do not refresh on `list_changed`, and those are initialize-era clients. +> See [Conformance](mcp-conformance.md#what-differs-by-revision). + When enabled: 1. The first `tools/list` returns only `discover_tools` and `call_tool` diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index cac9ce4e..7cc3c46e 100644 --- a/docs/mcp-agent-capabilities.md +++ b/docs/mcp-agent-capabilities.md @@ -8,14 +8,22 @@ See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow. +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Sampling and Logging features that `IMcpSampling` and `IMcpFeedback` build on, and the +> SDK may remove them in a future version. Repl keeps supporting them **for existing hosts +> and applications only** — new applications should not adopt these interfaces directly and +> should prefer the portable `IReplInteractionChannel`, which degrades gracefully across +> CLI, REPL, hosted sessions, and MCP. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. + ## Overview Repl provides three MCP-oriented injectable interfaces: | Interface | MCP capability | What it does | |---|---|---| -| `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) | Ask the connected LLM to generate a completion | -| `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) | Ask the user for structured input through the agent client | +| `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) | Ask the connected LLM to generate a completion | +| `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) | Ask the user for structured input through the agent client | | `IMcpFeedback` | Progress + logging/message notifications | Send MCP-specific runtime feedback during a tool call | They work like `IMcpClientRoots` — inject them into any command handler, check capability flags, and use them. They are automatically excluded from MCP tool schemas. @@ -238,12 +246,15 @@ public interface IMcpFeedback CancellationToken cancellationToken = default); ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default); } ``` +`McpMessageLevel` is Repl's own enum (`Debug` … `Emergency`), so this signature does not expose the +SDK's deprecated `LoggingLevel` to your build. + Use it when: - you need to control MCP progress/message notifications directly @@ -256,10 +267,13 @@ Use it when: app.Map("sync contacts", async (IMcpFeedback feedback, CancellationToken ct) => { - if (feedback.IsLoggingSupported) - { - await feedback.SendMessageAsync(LoggingLevel.Info, "Starting sync.", ct); - } + // Sending unconditionally is fine: a message the client cannot receive as a notification + // is carried back in the tool result instead. Check IsLoggingSupported only when you want + // to skip work that would otherwise be wasted — it reports that a threshold exists, not + // that this particular message clears it. + // That buffer keeps every level, including Debug: it exists only for a request that declared + // no log level, and it is not filtered. Internal diagnostics belong on ILogger, not here. + await feedback.SendMessageAsync(McpMessageLevel.Info, "Starting sync.", ct); if (feedback.IsProgressSupported) { @@ -322,7 +336,11 @@ if (!elicitation.IsSupported) For `IMcpFeedback`, the same idea applies: - check `IsProgressSupported` before sending MCP-only progress directly -- check `IsLoggingSupported` before sending MCP-only messages directly +- `IsLoggingSupported` tells you whether a message would arrive as a **notification**; it is `false` + on `2026-07-28` unless the request declared a log level. A message sent anyway rides back in the + tool or prompt result, so treat it as a hint rather than a gate — with one exception: a resource + read that **succeeds** keeps only its body, whose type is advertised, and drops the feedback on + purpose. A read that fails carries it in the surfaced error - prefer `IReplInteractionChannel` when the feedback should still render well outside MCP ## Client compatibility diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md new file mode 100644 index 00000000..96c034ef --- /dev/null +++ b/docs/mcp-conformance.md @@ -0,0 +1,129 @@ +# MCP Specification Conformance + +> **This page is for you if** you need to know exactly what Repl guarantees on a given MCP protocol revision, or why a behaviour differs between two clients. +> +> **Purpose:** One place to answer "what does Repl do on revision X". Every claim names the regression test that pins it. +> **Prerequisite:** [MCP overview](mcp-overview.md) +> **Related:** [Reference](mcp-reference.md) · [Transports](mcp-transports.md) · [Module presence](module-presence.md) + +## Supported revisions + +| Revision | Era | How Repl serves it | +| --- | --- | --- | +| `2026-07-28` | Modern — version, identity and capabilities travel as per-request `_meta`; there is no session | Default for an SDK client that does not pin a version | +| `2025-11-25` | Legacy — a session is established by an `initialize` handshake | Served when the client pins it, or opens with `initialize` | + +Repl is a dual-era server: a request carrying modern `_meta` is served statelessly, and an +`initialize` request selects legacy semantics. Pinned by +`Given_McpIntegration.When_ClientPinsLegacyProtocolVersion_Then_InitializeHandshakeAndToolsWork`. + +The era is a property of the request, not of the transport. **Statelessness is not an HTTP mode**: on +`2026-07-28` stdio has no session either, even though the process and its pipe outlive many requests. +Repl still caches per connection — that is an optimisation the protocol says nothing about — but +nothing a client can *observe* may depend on the connection. + +## What differs by revision + +| Behaviour | `2025-11-25` | `2026-07-28` | Pinned by | +| --- | --- | --- | --- | +| Advertised tool set may vary by client capabilities | Yes | **No** — invariant | `Given_McpConcurrentSessions.When_LegacySessionsShareAGatedGraph_Then_EachSeesItsOwnTools` / `...When_ModernSessionsShareAGatedGraph_Then_TheAdvertisedSetIsInvariant` | +| Soft roots reveal commands | Yes | **No** — they still resolve at execution | `Given_McpRootsAndDynamicTools.When_LegacyClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace` / `...When_ModernClientSetsSoftRoots_Then_TheyResolveWithoutChangingTheAdvertisedSet` | +| Compatibility bootstrap (`DynamicToolCompatibilityMode.DiscoverAndCallShim`) | First `tools/list` answers `discover_tools` / `call_tool`, the next the real catalog | Not served — the real catalog from the first request | `Given_McpConcurrentSessions.When_ShimEnabledAndTwoLegacySessionsList_Then_EachSessionGetsTheIntro` / `...When_ShimEnabledAndAModernSessionLists_Then_TheCatalogIsTheSameEveryTime` | +| Feedback on a failed tool, prompt or resource | Emitted as notifications | Carried in the surfaced error | `Given_McpUserFeedback.When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError` / `...When_AFailingResourceReadDeclaresNoLogLevel_Then_FeedbackRidesInTheError` / `Given_McpApps.When_AFailingUiResourceReadEmitsFeedback_Then_ItRidesInTheError` | +| `cacheScope` / `ttlMs` on list results | Absent — not in the schema | Set to private, zero TTL | `Given_McpIntegration.When_ClientPinsLegacyProtocolVersion_Then_ListResultsCarryNoCacheHints` / `Given_McpConcurrentSessions.When_ModernClientListsTools_Then_ListResultIsTaggedPrivateAndStale` | +| Message notifications for a request that declared no log level | Emitted, subject to the session threshold | **Not emitted** — the feedback rides in the result instead | `Given_McpUserFeedback.When_RequestDeclaresNoLogLevel_Then_FeedbackRidesInTheToolResultInstead` | +| Same, through `prompts/get` | Emitted | Rides in the prompt result after the payload, and in the surfaced error when the prompt fails | `Given_McpUserFeedback.When_APromptDeclaresNoLogLevel_Then_FeedbackRidesInThePromptResultInstead` / `...When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError` | + +## Tool list invariance on `2026-07-28` + +The tools chapter of that revision states: + +> This set **MAY** be empty and **MAY** change over time (see List Changed Notification), but +> **MUST NOT** vary per-connection or as a side effect of other requests on the connection. The set +> **MAY** vary by the authorization presented on the request — for example, returning only the tools +> the caller's granted scopes permit — since credentials are per-request input, not connection state. + +The earlier revisions carry no such rule, which is why the behaviours above are split by era rather +than changed outright. + +Two consequences, and they are different problems: + +- **Per-connection variance.** A module gated on `IMcpClientRoots.IsSupported` would advertise a + different set to a client that declares roots. On `2026-07-28` discovery answers every + per-connection question with a constant, so the set no longer depends on who asked. +- **Variance as a side effect.** A module gated on `HasSoftRoots` would appear after a `tools/call` + set them — changing the caller's own advertised set. This one needs no second connection to be + observable, so it is the half that matters even on plain stdio. A module gated on session state is + the same shape and reaches further: `IReplSessionState` is mutable, shared with execution, and a + command can write it and call `InvalidateRouting()`. The compatibility bootstrap is that shape too — + an intro catalog followed by the real one — and is therefore legacy-only. + +The discovery view reaches no live session-scoped service at all, rather than neutralising member by +member: `IsSupported`, `HasSoftRoots`, `Current` and `GetAsync` are all connection state, and +forwarding any one of them reopens the hole. The frozen set is the four capability services plus +`IReplSessionState` and `IReplSessionInfo` — every session-scoped input a presence predicate can +receive by injection. + +A predicate that injects an **application** service of its own is outside that set by construction: +Repl cannot know which of your singletons is stable and which a command mutates. Gate on something +that does not change, or keep the command mapped unconditionally and fail inside it. + +What stays allowed is a set that **changes over time** for everyone: `InvalidateRouting()` is +application-global, and the resulting `notifications/*/list_changed` reaches every connection with the +same new graph. + +### What this means when you write commands + +Module presence predicates still work, and still work on both eras. On `2026-07-28` discovery runs +them against **fixed answers** instead of against the client: + +| Member | What discovery answers | +| --- | --- | +| `IsSupported` (roots, sampling, elicitation), `IsLoggingSupported`, `IsProgressSupported` | `true` | +| `HasSoftRoots` | `false` | +| `Current`, `GetAsync()` | empty | + +Whatever your predicate returns under those answers is what **every** client is offered. The bucket a +command lands in therefore follows the predicate's *result*, not which member it reads — a negated +gate lands in the opposite bucket from the plain one. Two consequences worth stating in full: + +- A predicate that comes out **true** — `roots.IsSupported`, `sampling.IsSupported` — advertises its + command to every client. Repl guarantees such a command is **reachable**: execution decides presence + from the same fixed answers, so what was advertised can be called, and the handler runs with the + real client rather than the catalog's view of it. Writing the failure is then yours — return an + error naming the missing capability rather than relying on the command being absent. That is the + shape the specification prescribes: a tool execution error is "actionable feedback that language + models can use to self-correct". +- A predicate that comes out **false** — `!roots.IsSupported`, `roots.HasSoftRoots`, + `roots.Current.Count > 0` — advertises its command to no client at all, and it disappears with no + error to explain it. Map those commands unconditionally instead. + +The soft-roots bootstrap pattern gates on `!roots.IsSupported`, so despite reading a capability it +lands in the second group. That is the reason to read the rule off the predicate's result rather than +off the member it consults. + +Execution is untouched either way. `SetSoftRoots` still works, and `IMcpClientRoots.Current` answers +with the connection's real roots under `mcp serve`; on a reused `BuildMcpServerOptions()` result it +answers empty until *this request* has called `GetAsync`, which is that path's documented contract. + +For state that must survive across calls, the specification's own answer is an explicit handle +returned by a creation tool and passed back as an argument, rather than implicit connection state. + +## Deliberate gaps + +| Gap | Why | Tracked | +| --- | --- | --- | +| No per-caller command graph | The one variance `2026-07-28` permits is by the authorization presented on the request. Repl has no request-authorization concept yet, so it advertises one graph to everyone. | [#97](https://github.com/yllibed/repl/issues/97) | +| Explicitly registered prompts cannot inject the MCP capability services | The SDK resolves their parameters from a scope taken from the inner container, which the service overlay does not reach. | [#96](https://github.com/yllibed/repl/issues/96) | +| `*/list_changed` is advertised on the reusable-options path but never fires there | The SDK forces the flag true for any non-null collection, and the pre-built catalog always supplies one. | [#94](https://github.com/yllibed/repl/issues/94) | +| A multi-connection custom transport sees considerations this page does not solve | `mcp serve` is one connection per process; a host that multiplexes connections over one options instance owns the isolation questions that follow. See [Transports](mcp-transports.md). | — | + +## Extensions and SEPs + +| Identifier | Status in Repl | +| --- | --- | +| SEP-2549 — `cacheScope` / `ttlMs` | Set on list and resource results, on `2026-07-28` only | +| SEP-2575 — stateless requests: per-request `_meta`, and no message notification without a declared log level | The protocol version in `_meta` is what selects the era on every request; the log-level rule is honoured, and the feedback is appended to the result instead | +| SEP-2577 — Roots, Sampling and Logging deprecated | Still supported for the compatibility path; the SDK reports them under diagnostic `MCP9005` | +| SEP-2567 — protocol sessions removed; list endpoints made session-independent | The source of the invariance rule above; cross-call state becomes an explicit handle passed as a tool argument, not connection state | +| Tasks (`io.modelcontextprotocol/tasks`) | Not advertised; the SDK moved it out of the core package | diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index 9f3f42a3..ac42537d 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -64,7 +64,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld(); | `.Destructive()` | Ask user for confirmation, sequential | | `.Idempotent()` | Safe to retry, can parallelize | | `.OpenWorld()` | Reaches external systems — expect latency and transient failures | -| `.LongRunning()` | Enables call-now/poll-later pattern | +| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns once Repl integrates the SDK Tasks extension — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) | | `.AutomationHidden()` | Not visible to agents | **Annotate every command exposed to agents.** Unannotated tools force agents to assume the worst: confirm everything, no parallelism, no retries. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 962da647..7157d3a0 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -4,7 +4,7 @@ > > **Purpose:** Complete reference for MCP server features. Consult, don't read end-to-end. > **Prerequisite:** [MCP overview](mcp-overview.md) -> **Related:** [Advanced patterns](mcp-advanced.md) · [Sampling & elicitation](mcp-agent-capabilities.md) · [Transports](mcp-transports.md) +> **Related:** [Advanced patterns](mcp-advanced.md) · [Sampling & elicitation](mcp-agent-capabilities.md) · [Transports](mcp-transports.md) · [Conformance](mcp-conformance.md) ## Rich descriptions @@ -289,6 +289,13 @@ The interaction channel is the preferred API when the feedback should stay porta | `WriteWarningAsync(...)` | warning-level message notification | | `WriteProblemAsync(...)` | error-level message notification | +Those notification rows describe an initialize-era session. On `2026-07-28` a request that declared no +`_meta/io.modelcontextprotocol/logLevel` must not receive message notifications at all, so the same +calls are appended to the tool result instead — see +[SDK and protocol versions](#sdk-and-protocol-versions). A resource read that _succeeds_ has nowhere +to put them — its body must match the advertised MIME type — so it drops them; a read that fails +carries them in the surfaced error, which is the only place left for them. + Notes: - `ClearProgressAsync()` clears local host rendering. MCP clients typically just stop receiving progress updates and then see the final tool result. @@ -512,6 +519,83 @@ side-channel command output and are not included in `resources/read` bodies. Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data. +### SDK and protocol versions + +- Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently at **2.2.0**. The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. +- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) shipped experimentally in the SDK 2.0 preview line and is stable as of 2.2.0; Repl has not adopted it yet. +- **Discovery notifications** follow the negotiated revision. Repl drives the SDK's own fan-out rather than broadcasting itself, so an initialize-era client keeps receiving unsolicited `*/list_changed` while a `2026-07-28` client receives only the notification types it requested through `subscriptions/listen`, each tagged with its listen request id (SEP-2575). A modern client that opens no subscription receives none — which is what the specification requires. List results carry `ttlMs: 0`, so such a client re-lists on demand rather than caching. +- **User feedback** (`notice` / `warning` / `problem`, and `IMcpFeedback.SendMessageAsync`) follows the same split. On `2026-07-28`, `logging/setLevel` is gone and a server must not emit `notifications/message` for a request that declared no `_meta/io.modelcontextprotocol/logLevel`. Messages that cannot be delivered as notifications are appended to the **tool result** instead, after the command's own payload, so no host loses them. The exception is a resource read that succeeds: its result is a typed body, so buffered feedback is dropped rather than appended — a read that _fails_ carries it in the surfaced error. Initialize-era clients keep the session-wide `logging/setLevel` behaviour unchanged. Note that the SDK's own client cannot request a level on `2026-07-28` at all, so in practice modern hosts see feedback in the tool result. +- **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72). + +### Upgrading from the 1.x SDK + +Seven things change for an application that already references `Repl.Mcp`. The first two are build +breaks; the rest are behaviour a consumer meets at runtime. + +**The SDK moves to 2.x.** `ModelContextProtocol` is a transitively public dependency, so a consumer +that also references it directly has to move with this package. There is no compatibility shim: the +1.x and 2.x assemblies cannot coexist in one dependency graph. + +**`IMcpFeedback.SendMessageAsync` takes `McpMessageLevel`** instead of the SDK's `LoggingLevel`. The +members and their numeric values are identical, so the swap is mechanical. It is not cosmetic, +though: `LoggingLevel` carries the SDK's `MCP9005` deprecation, and a `#pragma` inside Repl never +covered a _consumer's_ compilation — anyone building with warnings as errors got a hard error on a +Repl signature. + +**Tool results can carry more content blocks than before.** A message a command reported that the +client could not receive as a notification is appended to the tool result. The command's own payload +stays the first block and `StructuredContent` is untouched, so a caller reading either is unaffected +— but a test asserting the result has exactly one content block will now fail. See the **User feedback** +bullet under [SDK and protocol versions](#sdk-and-protocol-versions) for when this happens. + +**Module presence no longer varies with the client on `2026-07-28`.** That revision requires the +advertised set not to vary per-connection, nor to change as a side effect of another request on the +connection, so discovery there runs every presence predicate against fixed answers: `IsSupported`, +`IsLoggingSupported` and `IsProgressSupported` are true, `HasSoftRoots` is false, `Current` and +`GetAsync()` are empty. The predicate still runs normally on the earlier revisions and outside MCP. + +Whatever the predicate returns under those answers is what every client is offered, so read the rule +off the **result**, not off the member: + +- Comes out true (`roots.IsSupported`, `sampling.IsSupported`): advertised to **every** client, and + the command now has to fail with a clear error when the capability is in fact missing rather than + rely on being absent. +- Comes out false (`!roots.IsSupported`, `roots.HasSoftRoots`, `roots.Current.Count > 0`): advertised + to **no** client, disappearing with no error to explain it. Map those commands unconditionally. The + soft-roots bootstrap gates on `!roots.IsSupported` and falls here despite reading a capability. + +On a reused `BuildMcpServerOptions()` result this applies to **every** client, including an +initialize-era one: that catalog is built once, before any request names an era, so it is built with +the modern view and served as-is to whoever connects. The per-era behaviour above is what `mcp serve` +gives you, where the catalog is built per connection. + +Execution is untouched: `SetSoftRoots` still works, and `IMcpClientRoots.Current` answers with the +connection's real roots under `mcp serve` — on a reused `BuildMcpServerOptions()` result it answers +empty until this request has called `GetAsync`, as the bullet below already states. See +[Conformance](mcp-conformance.md#tool-list-invariance-on-2026-07-28). + +**`.LongRunning()` no longer advertises task support on the protocol surface**, because SDK 2.x +removed the per-tool execution augmentation. The annotation still carries into help and documentation; +protocol-level task support returns with issue #72. + +**`IsLoggingSupported` is `false` for every SDK-client request on `2026-07-28`.** A command that +guards expensive work on it will now skip that work against a modern host. Messages sent anyway ride +back in the tool result, so the usual fix is to stop guarding — except during a resource read, where +there is nowhere to put them and they are dropped. + +**Native roots are resolved per request on a reused `BuildMcpServerOptions()` result.** Previously one +connection's `roots/list` answer was cached for the life of the options instance and handed to every +other connection; it is now fetched at most once per request and forgotten with it. Two consequences +for a command on that hosting path: `GetAsync` costs a round-trip per request rather than one in +total, and `Current` answers empty until _this_ request has called `GetAsync`. + +Under `mcp serve` the cost is unchanged — one `roots/list` per connection — but `Current` now falls +back to soft roots while nothing native has been resolved, whether because it has not been asked yet +or because the client could not be reached. An empty answer therefore means the roots in force are +empty, not that resolving them failed; a client that genuinely answers with zero roots is still told +apart, since that answer counts as resolved. Call `GetAsync` when the difference matters: it resolves +on demand and surfaces the failure instead of absorbing it. + | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| | Tools | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/mcp-transports.md b/docs/mcp-transports.md index e8d71c4d..08334cdf 100644 --- a/docs/mcp-transports.md +++ b/docs/mcp-transports.md @@ -46,6 +46,35 @@ async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken } ``` +Client capabilities (sampling, elicitation, roots) resolve per **request** on this path, so each +connection sees its own — that is a property of the request, not of the options instance. + +> **Known limitation:** cross-call state does not resolve per request, and three consequences are +> worth knowing before you choose this shape. The `2026-07-28` revision removed protocol-level +> sessions, so this path has no per-connection identity to hang state on. +> +> [Soft roots](mcp-advanced.md#soft-roots-fallback) set by one connection are visible to every other +> connection built from the same options — they are host-set state with no request to belong to. If +> your commands rely on them, host one server per process (`mcp serve`) or pass the workspace as an +> explicit command argument. Note that on `2026-07-28` soft roots never reveal commands on any +> transport: the advertised set must not change as a side effect of a `tools/call`. See +> [Conformance](mcp-conformance.md#tool-list-invariance-on-2026-07-28). +> +> The command catalog is frozen when `BuildMcpServerOptions()` returns. A server built from it never +> emits `*/list_changed`, even though the SDK advertises the capability, so a client that would +> refresh on that notification never does. Commands whose visibility changes at runtime — dynamic +> tools — need `mcp serve`. Presence predicates are a separate matter on `2026-07-28`: discovery +> resolves every per-connection question to a constant there, so the predicate's value under those +> constants decides presence once and for all. One that evaluates true is advertised to every client; +> one that evaluates false — a data gate, or a negated capability gate such as `!roots.IsSupported` — +> is advertised to none, and a frozen catalog has no later chance to change its mind. See +> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands). +> +> Native roots are safe here: they are resolved per request rather than cached per connection, so one +> client never sees another's workspace. The cost is one `roots/list` round-trip per request that asks +> for them. Under stateless HTTP the SDK reports no client capabilities at all, so native roots are +> unavailable on that transport whatever you do. + ## Scenario B: MCP-over-HTTP The MCP spec also defines an HTTP transport. For that, you typically host MCP inside ASP.NET Core rather than through `mcp serve`. @@ -64,14 +93,19 @@ var mcpOptions = app.Core.BuildMcpServerOptions(configure: o => You can then pass those options to the MCP SDK's HTTP integration. -## Session isolation +## What is isolated, and at which boundary -Each connection or HTTP session is isolated: +The `2026-07-28` revision removed protocol-level sessions: the client declares its capabilities on +every request rather than once per connection. So the boundaries are not all the same size. -- its own MCP session -- its own I/O capture -- its own session-aware routing state +| Isolated per | What | +|---|---| +| Request | Client capabilities, the requested log level, the destination for sampling, elicitation and progress, and — on a reused `BuildMcpServerOptions()` result — native roots, resolved once per request and never cached across connections | +| Invocation | I/O capture — each tool call gets its own capture scope, not one per connection | +| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, and session-aware routing state | -That matters especially when using dynamic tools, roots, or session-specific modules. +A server created from a reused `BuildMcpServerOptions()` result has everything above except the +connection row; see the known limitation above. That matters especially when using dynamic tools, +roots, or session-specific modules. For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md). diff --git a/docs/module-presence.md b/docs/module-presence.md index aa51ec34..338cdc20 100644 --- a/docs/module-presence.md +++ b/docs/module-presence.md @@ -2,6 +2,18 @@ This page explains how to make modules appear/disappear dynamically at runtime. +> **Serving MCP?** On revision `2026-07-28` the advertised tool set must not vary per connection or +> change as a side effect of another request, so discovery answers every session-scoped question with +> fixed answers: capability checks read as supported, soft roots as absent, the root list as empty, +> **and the session state as empty**. Whatever your predicate returns under those answers is what +> every client is offered, so a negated gate such as `!roots.IsSupported` matches for nobody even +> though it reads a capability — and the sign-in flow below reveals nothing, because the state it +> writes is not what discovery reads. Gate on something that does not change, or map the command +> unconditionally and refuse inside it. A command that *is* advertised stays callable, so refusing +> inside it is what the caller can act on. The predicate still runs everywhere else, and the earlier +> revisions are unaffected — see +> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands). + ## Why Sometimes the command surface depends on session state: @@ -82,6 +94,11 @@ Example flow: 3. App invalidates routing cache. 4. Signed-in module becomes present on next command resolution. +This flow works in the console, over the earlier MCP revisions, and anywhere else. It does **not** +change what an MCP client on `2026-07-28` is offered: that revision forbids the advertised set from +moving as a side effect of another request, which is exactly what step 2 would be. See the note at the +top of this page. + ## Conflict policy If two **active** modules map the same route, **last registration wins**. From d0918e2f329d04eb752ca387f9ed3bcd996e2af2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 14:29:42 -0400 Subject: [PATCH 04/15] =?UTF-8?q?fix(mcp):=20withdraw=20the=20shared=20sna?= =?UTF-8?q?pshot=20fallback=20=E2=80=94=20it=20crossed=20session=20boundar?= =?UTF-8?q?ies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared last-known-good catalog introduced two commits ago shared the wrong thing. A snapshot is not an immutable catalog: its tools, resources, prompts and their adapter capture the session's own services, including its connection-scoped roots service. Letting one connection fall back to another's snapshot therefore executes with that connection's roots — one client's workspace reaching another, which is the defect this whole migration exists to remove. Reported on #71 as a P1 against the commit that introduced it. There is no cheap repair: the fallback exists precisely because this connection's own projection failed, so there is nothing to rebuild its primitives from. Sharing only the catalog decision would still leave the executable half unusable. So the per-session fallback is restored, and the invariance gap it leaves — two modern connections can serve different sets while a transient failure lasts — stands open rather than being closed with something worse. The guard that pinned the shared behaviour is withdrawn with it; keeping a green test for a design that is being taken back would misrepresent what is covered. Two smaller faults from the same commit go with it. The eligibility check ran twice, once in the exception filter and once in the body, with a null-forgiving operator between them: a retraction landing in that window turned the original projection failure into a NullReferenceException. It now resolves once, re-checks before serving, and rethrows the original failure when the candidate has gone. --- src/Repl.Mcp/McpServerHandler.cs | 59 +++++-------------- .../Given_McpConcurrentSessions.cs | 42 ------------- 2 files changed, 16 insertions(+), 85 deletions(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index dc23a339..d775546a 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -41,7 +41,6 @@ internal sealed class McpServerHandler // Global routing version: bumped by InvalidateRouting for every session; each session's // context caches the snapshot it built at a given version. private SnapshotVersionState _snapshotState = new(Version: 1, LastVisibilityRetractionVersion: 0); - private McpSessionContext.SnapshotCacheEntry? _lastGoodSessionlessSnapshot; // One handler can serve several concurrent sessions; everything session-owned lives in // McpSessionContext, and this list (guarded by _attachLock) tracks every ACTIVE session // for server-initiated notifications and subscription lifetime. @@ -403,7 +402,6 @@ private async ValueTask GetSnapshotAsync( { var built = await BuildCurrentSnapshotAsync(context, snapshotVersion, sessionless, cancellationToken) .ConfigureAwait(false); - RememberSharedFallback(built, snapshotVersion, sessionless); return built; } catch (OperationCanceledException) @@ -415,14 +413,20 @@ private async ValueTask GetSnapshotAsync( ThrowSanitizedIfAClientAlreadyHasASchema(previousSnapshot); throw; } - catch (Exception) when ( - ResolveFallback(context, sessionless) is not null) + catch (Exception) when (IsFallbackEligible(context, sessionless)) { // Preserve availability for transient projection failures, but republish as stale so the // next request retries without requiring another routing mutation. The entry keeps the // version it was built at, because the retraction comparison reads it: a sentinel version // would count as older than every retraction and take the fallback away after the first. - var fallback = ResolveFallback(context, sessionless)!; + // Re-read rather than reuse the filter's value: nothing holds the retraction watermark + // still between the two, and a retraction that lands in between must fail closed with the + // original failure rather than serve a catalog it has just withdrawn. + if (context.SnapshotCache is not { } fallback || !IsFallbackEligible(context, sessionless)) + { + throw; + } + context.PublishStaleSnapshot(fallback.Snapshot, fallback.Version, fallback.Sessionless); return fallback.Snapshot; } @@ -611,48 +615,17 @@ private void AttachSession(McpSessionContext context, McpServer server) } } - // The fallback every modern connection shares, so a transient failure cannot leave two of them - // serving different sets. The initialize era keeps using each connection's own previous catalog. - private void RememberSharedFallback(McpGeneratedSnapshot built, long version, bool sessionless) - { - if (sessionless) - { - Volatile.Write( - ref _lastGoodSessionlessSnapshot, - new McpSessionContext.SnapshotCacheEntry(built, version, IsStale: false, Sessionless: true)); - } - } - /// - /// What a failed projection may serve instead, or when nothing may. + /// Whether a failed projection may serve this connection's previous catalog instead. /// /// - /// Availability is preserved on both revisions, but not from the same place. The initialize era - /// falls back to the connection's own previous catalog, which is what that revision serves anyway. - /// On 2026-07-28 the advertised set must not vary per connection, and a per-session fallback - /// is precisely how it would: one connection would keep its previous catalog while another, whose - /// build succeeded or which connected later, serves the new one. Modern connections therefore share - /// one last-known-good catalog, so a transient failure moves all of them together or none. - /// - /// Either way a catalog retracted for visibility is never re-served: that failure has to fail - /// closed, since the retraction is the whole point. - /// + /// A catalog retracted for visibility is never re-served: that failure has to fail closed, since + /// the retraction is the whole point. /// - private McpSessionContext.SnapshotCacheEntry? ResolveFallback(McpSessionContext context, bool sessionless) - { - var candidate = sessionless - ? Volatile.Read(ref _lastGoodSessionlessSnapshot) - : context.SnapshotCache; - - if (candidate is null || candidate.Sessionless != sessionless) - { - return null; - } - - return Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion <= candidate.Version - ? candidate - : null; - } + private bool IsFallbackEligible(McpSessionContext context, bool sessionless) => + context.SnapshotCache is { } candidate + && candidate.Sessionless == sessionless + && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion <= candidate.Version; internal sealed record SnapshotVersionState( long Version, diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index c3b2d5e5..a6960e34 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -283,48 +283,6 @@ public async Task When_AModernClientCallsAnUnadvertisedGatedTool_Then_ItIsStillN because: "a command the catalog never offered must not become reachable by name"); } - [TestMethod] - [Description("Regression guard: on 2026-07-28 a transient projection failure must not leave two connections serving different catalogs. Availability is worth preserving on both revisions, but a per-session fallback buys it with exactly the variance this revision forbids \u2014 a connection that had not yet seen a routing change would keep its older set while another served the newer one. Modern connections fall back to one shared last-known-good catalog instead, so they move together or not at all.")] - public async Task When_AModernProjectionFailsTransiently_Then_EverySessionFallsBackTogether() - { - var app = ReplApp.Create(); - app.UseMcpServer(); - app.Map("always", () => "ok"); - var breakProjection = false; - var handler = CreateHandlerWithAppServices( - app, - options => options.CommandFilter = _ => breakProjection - ? throw new InvalidOperationException("projection-failure") - : true); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - - var older = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); - await using var olderScope = older.ConfigureAwait(false); - - // This connection reads the catalog before the change, and deliberately never reads it again - // until the failure: it is the one that would otherwise be left behind. - (await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) - .Should().NotContain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); - - app.Map("added", () => "new"); - app.Core.InvalidateRouting(); - - var newer = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); - await using var newerScope = newer.ConfigureAwait(false); - (await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) - .Should().Contain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); - - breakProjection = true; - app.Core.InvalidateRouting(); - - var olderTools = await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); - var newerTools = await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); - - olderTools.Select(static tool => tool.Name).Should().BeEquivalentTo( - newerTools.Select(static tool => tool.Name), - because: "the set MUST NOT vary per connection, and a failure is not an exception to that"); - } - /// An app whose module appears only once a command has written the session state. private static ReplApp BuildSessionGatedApp() { From ac84f51c8c820c6f0527121a845bc38fdb700f2f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 14:33:11 -0400 Subject: [PATCH 05/15] fix(core): unwrap reflection's own layer before showing an operator the cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking application callbacks put the cause behind one wrapper, and the local renderer unwrapped one layer. But a property setter, an options-group constructor and a keyed-service factory all reach the binder through reflection, which adds a layer of its own — so those three produced "Exception has been thrown by the target of an invocation", which is true and useless. Only the direct service factory, the case I had measured, came out right. Unwrapping now continues until the application's own failure is what remains. The conformance page also listed explicitly registered prompts as unable to inject the MCP capability services. They can, as of the previous commit, and the tests that prove it ship with it — leaving the row there would tell a reader to avoid a feature that works, or to follow an issue that is closed. --- docs/mcp-conformance.md | 1 - src/Repl.Core/CoreReplApp.Execution.cs | 21 +++++++++++++---- src/Repl.Tests/Given_ExitCodes.cs | 32 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 96c034ef..3d0aec4d 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -114,7 +114,6 @@ returned by a creation tool and passed back as an argument, rather than implicit | Gap | Why | Tracked | | --- | --- | --- | | No per-caller command graph | The one variance `2026-07-28` permits is by the authorization presented on the request. Repl has no request-authorization concept yet, so it advertises one graph to everyone. | [#97](https://github.com/yllibed/repl/issues/97) | -| Explicitly registered prompts cannot inject the MCP capability services | The SDK resolves their parameters from a scope taken from the inner container, which the service overlay does not reach. | [#96](https://github.com/yllibed/repl/issues/96) | | `*/list_changed` is advertised on the reusable-options path but never fires there | The SDK forces the flag true for any non-null collection, and the pre-built catalog always supplies one. | [#94](https://github.com/yllibed/repl/issues/94) | | A multi-connection custom transport sees considerations this page does not solve | `mcp serve` is one connection per process; a host that multiplexes connections over one options instance owns the isolation questions that follow. See [Transports](mcp-transports.md). | — | diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index b2e7adc6..4e3d7475 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -136,10 +136,23 @@ internal ValueTask RunOutcomeWithServicesAsync( /// somebody other than the operator can withhold that cause — it decides by the exception's type, not /// by this text — and here the reader is the operator, who came for exactly that cause. /// - private static string DescribeLocally(Exception exception) => - exception is ReplBindingCallbackException { InnerException: { } cause } - ? cause.Message - : exception.Message; + private static string DescribeLocally(Exception exception) + { + var cause = exception is ReplBindingCallbackException { InnerException: { } marked } + ? marked + : exception; + + // Reflection adds its own layer on top of what the application threw — a property setter, an + // options-group constructor and a keyed-service factory all reach the binder through it — and + // "Exception has been thrown by the target of an invocation" is not the diagnostic the operator + // came for. Unwrap until the application's own failure is what remains. + while (cause is System.Reflection.TargetInvocationException { InnerException: { } deeper }) + { + cause = deeper; + } + + return cause.Message; + } private async ValueTask RunUnderCancellationPolicyAsync( IReadOnlyList args, diff --git a/src/Repl.Tests/Given_ExitCodes.cs b/src/Repl.Tests/Given_ExitCodes.cs index 2767a0ec..8915a8b9 100644 --- a/src/Repl.Tests/Given_ExitCodes.cs +++ b/src/Repl.Tests/Given_ExitCodes.cs @@ -986,6 +986,38 @@ public async Task When_ADependencyFactoryThrows_Then_TheLocalDiagnosticNamesTheC because: "the operator is the reader here, and the cause is the whole content of the diagnostic"); } + [TestMethod] + [Description("The same for an options-group property setter, which reaches the binder through reflection. Reflection wraps what the application threw in its own exception, so unwrapping a single layer would leave the operator with reflection's generic target-of-an-invocation message — true, and useless.")] + public async Task When_AnOptionsGroupSetterThrows_Then_TheLocalDiagnosticNamesTheCause() + { + var sut = ReplApp.Create(); + sut.Map("work", (FailingOptions options) => options.Label ?? "ok"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + using var session = OpenSession(out var writer); + await sut.RunAsync(["work", "--label", "x"], cts.Token).ConfigureAwait(false); + + writer.ToString().Should().Contain( + "setter-cause-detail", + because: "reflection's own wrapper is not the diagnostic, it is what hides it"); + } + + [Repl.Parameters.ReplOptionsGroup] + public sealed class FailingOptions + { + private string? _label; + + public string? Label + { + get => _label; + set + { + _label = value; + throw new InvalidOperationException("setter-cause-detail"); + } + } + } + /// A dependency whose registration always fails; only its activation path matters. public interface IFailingDependency; From 381b94f4e74a21d6031e3302f1ee56bcacea23b6 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 16:03:20 -0400 Subject: [PATCH 06/15] fix(mcp): fail closed when a modern projection fails 2026-07-28 requires the advertised set not to vary per connection, and the availability fallback varied it: a connection that had not read the catalog since the last routing change kept serving its older set while another already served the newer one, and a projection failure held that difference in place for as long as it lasted. Restrict the fallback to the initialize era, where the catalog is session state and a set that differs per connection is the point. A modern request now fails instead, and retries on the next one without needing another invalidation. Sharing one last-known-good catalog across modern connections was the other way to converge, and it is the worse one: the snapshot carries the executable primitives, which captured the services of whichever connection built it. The two stale-route guards become initialize-era guarantees, and a discriminating two-session guard pins that no modern connection is answered from its own cache. --- docs/mcp-conformance.md | 8 +++ src/Repl.Mcp/McpServerHandler.cs | 26 +++++++-- .../Given_McpConcurrentSessions.cs | 56 +++++++++++++++++++ src/Repl.McpTests/Given_McpDebounce.cs | 23 +++++--- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 3d0aec4d..0d4312c8 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -72,6 +72,14 @@ What stays allowed is a set that **changes over time** for everyone: `Invalidate application-global, and the resulting `notifications/*/list_changed` reaches every connection with the same new graph. +When a rebuild **fails**, the eras diverge for that same reason. An initialize-era session keeps +serving its previous catalog until the failure clears — there the catalog is session state, and a set +that differs per connection is the point. A `2026-07-28` request fails instead: answering it from its +own cache is the per-connection variance above, because a connection that had not read the catalog +since the last change would keep its older set while another already serves the newer one, and the +failure would hold that difference in place for as long as it lasts. Failing is transient — the next +request retries, without needing another `InvalidateRouting()`. + ### What this means when you write commands Module presence predicates still work, and still work on both eras. On `2026-07-28` discovery runs diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index d775546a..236f6809 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -396,8 +396,7 @@ private async ValueTask GetSnapshotAsync( return refreshed.Snapshot; } - var previousEntry = context.SnapshotCache; - var previousSnapshot = previousEntry?.Snapshot; + var previousSnapshot = context.SnapshotCache?.Snapshot; try { var built = await BuildCurrentSnapshotAsync(context, snapshotVersion, sessionless, cancellationToken) @@ -419,6 +418,7 @@ private async ValueTask GetSnapshotAsync( // next request retries without requiring another routing mutation. The entry keeps the // version it was built at, because the retraction comparison reads it: a sentinel version // would count as older than every retraction and take the fallback away after the first. + // Initialize-era only — see IsFallbackEligible. // Re-read rather than reuse the filter's value: nothing holds the retraction watermark // still between the two, and a retraction that lands in between must fail closed with the // original failure rather than serve a catalog it has just withdrawn. @@ -619,11 +619,27 @@ private void AttachSession(McpSessionContext context, McpServer server) /// Whether a failed projection may serve this connection's previous catalog instead. /// /// - /// A catalog retracted for visibility is never re-served: that failure has to fail closed, since - /// the retraction is the whole point. + /// Only on the initialize era, where the catalog is session state and a set that differs per + /// connection is the point. On 2026-07-28 the advertised set MUST NOT vary per connection, + /// and serving one its own previous catalog is exactly that variance: a connection that had not + /// read the catalog since a routing change keeps its older set while another already serves the + /// newer one, and the failure freezes the difference in place for as long as it lasts. Buying + /// availability that way spends the guarantee on the thing the guarantee exists to prevent, so a + /// modern request fails instead and retries on the next one. + /// + /// Sharing one last-known-good catalog across modern connections is not the way out either: the + /// snapshot carries the executable primitives, which captured the services of whichever connection + /// built it — including its roots. Converging the advertised set that way would hand one + /// connection another's workspace. + /// + /// + /// On either era, a catalog retracted for visibility is never re-served: that failure has to fail + /// closed, since the retraction is the whole point. + /// /// private bool IsFallbackEligible(McpSessionContext context, bool sessionless) => - context.SnapshotCache is { } candidate + !sessionless + && context.SnapshotCache is { } candidate && candidate.Sessionless == sessionless && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion <= candidate.Version; diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index a6960e34..da15ee5d 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -283,6 +283,62 @@ public async Task When_AModernClientCallsAnUnadvertisedGatedTool_Then_ItIsStillN because: "a command the catalog never offered must not become reachable by name"); } + [TestMethod] + [Description("Regression guard: on 2026-07-28 the advertised set MUST NOT vary per connection, and a transient projection failure is not an exception to it. A per-session stale fallback is exactly how it varies — a connection that had not yet read the catalog since a routing change keeps serving its older set while another already serves the newer one, and the failure freezes that difference in place for as long as it lasts. Modern requests fail closed instead, so the connections move together or not at all; the initialize era keeps the fallback, where a set that differs per session is the point.")] + public async Task When_AModernProjectionFailsTransiently_Then_NoSessionServesItsOwnStaleCatalog() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + var breakProjection = false; + var handler = CreateHandlerWithAppServices( + app, + options => options.CommandFilter = _ => breakProjection + ? throw new InvalidOperationException("projection-failure") + : true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var older = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var olderScope = older.ConfigureAwait(false); + + // This connection reads the catalog before the change and deliberately never reads it again + // until the failure: it is the one a per-session fallback would leave behind. + (await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().NotContain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); + + app.Map("added", () => "new"); + app.Core.InvalidateRouting(); + + var newer = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var newerScope = newer.ConfigureAwait(false); + (await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); + + breakProjection = true; + app.Core.InvalidateRouting(); + + var readOlder = async () => await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var readNewer = async () => await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + await readOlder.Should().ThrowAsync( + because: "answering this connection with its own older catalog is the variance the revision forbids") + .ConfigureAwait(false); + await readNewer.Should().ThrowAsync( + because: "failing closed has to reach both, or the difference survives as one set against none") + .ConfigureAwait(false); + + // Failing closed has to stay transient: clearing the failure lets the same invalidated version + // retry without another routing mutation, and both connections land on the same set. + breakProjection = false; + var recoveredOlder = await older.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var recoveredNewer = await newer.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + recoveredOlder.Select(static tool => tool.Name).Should().BeEquivalentTo( + recoveredNewer.Select(static tool => tool.Name), + because: "the failure withheld the catalog; it must not have left the connections on different ones"); + recoveredOlder.Should().Contain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); + } + /// An app whose module appears only once a command has written the session state. private static ReplApp BuildSessionGatedApp() { diff --git a/src/Repl.McpTests/Given_McpDebounce.cs b/src/Repl.McpTests/Given_McpDebounce.cs index c5dcd17e..9ecc8876 100644 --- a/src/Repl.McpTests/Given_McpDebounce.cs +++ b/src/Repl.McpTests/Given_McpDebounce.cs @@ -44,11 +44,11 @@ public void When_MultipleInvalidations_Then_SingleRebuildAfterDebounce() } [TestMethod] - [Description("Exception during routing rebuild does not crash the server.")] - public void When_RebuildThrows_Then_ServerContinuesWithStaleRoutes() + [Description("Exception during routing rebuild does not crash an initialize-era session, which keeps serving its previous catalog. The fallback is bounded to that era on purpose: 2026-07-28 forbids the advertised set from varying per connection, and answering one connection from its own cache is precisely that variance, so a modern request fails closed instead.")] + public void When_RebuildThrows_Then_ALegacySessionContinuesWithStaleRoutes() { var fakeTime = new FakeTimeProvider(); - using var fixture = CreateServerFixture(fakeTime); + using var fixture = CreateServerFixture(fakeTime, BuildLegacyClientOptions()); // Verify initial state — tool is available. var tools = SyncWait(fixture.Client.ListToolsAsync().AsTask()); @@ -73,11 +73,11 @@ public void When_RebuildThrows_Then_ServerContinuesWithStaleRoutes() } [TestMethod] - [Description("Regression guard: verifies the availability fallback keeps applying after a visibility retraction. Republishing a served-but-stale snapshot used to overwrite the version it was built at with a zero sentinel, so the retraction watermark was compared against zero and read as older than every retraction ever published. The FIRST failed projection still served the previous catalog and the second surfaced the error instead — a catalog that had been serving a moment earlier became unreachable for as long as the failure lasted. Hiding a command is the retraction: without one the watermark stays at zero, where the sentinel happened to compare equal and the defect is invisible.")] - public void When_ProjectionKeepsFailingAfterARetraction_Then_ThePreviousCatalogKeepsServing() + [Description("Regression guard: verifies the availability fallback keeps applying after a visibility retraction. Republishing a served-but-stale snapshot used to overwrite the version it was built at with a zero sentinel, so the retraction watermark was compared against zero and read as older than every retraction ever published. The FIRST failed projection still served the previous catalog and the second surfaced the error instead — a catalog that had been serving a moment earlier became unreachable for as long as the failure lasted. Hiding a command is the retraction: without one the watermark stays at zero, where the sentinel happened to compare equal and the defect is invisible. Initialize-era, because that is the only era the availability fallback applies to.")] + public void When_ProjectionKeepsFailingAfterARetraction_Then_ALegacySessionKeepsServingThePreviousCatalog() { var fakeTime = new FakeTimeProvider(); - using var fixture = CreateServerFixture(fakeTime); + using var fixture = CreateServerFixture(fakeTime, BuildLegacyClientOptions()); var extra = fixture.App.Map("extra", static () => "x"); SyncWait(fixture.Client.ListToolsAsync().AsTask()) @@ -190,6 +190,10 @@ public void When_VisibilityRetractionRefreshFails_Then_StaleSnapshotIsNotServed( // (MCP client) are awaited via bounded Wait() to fail fast on deadlock. #pragma warning disable VSTHRD002 // Intentional sync-over-async for deterministic time tests. + /// A client that negotiates the initialize era, where the catalog is session state. + private static McpClientOptions BuildLegacyClientOptions() => + new() { ProtocolVersion = McpProtocolRevisions.LastWithSessions }; + private static T SyncWait(Task task) { if (!task.Wait(TimeSpan.FromSeconds(10))) @@ -203,7 +207,9 @@ private static T SyncWait(Task task) // ── Fixture ───────────────────────────────────────────────────────── - private static ServerFixture CreateServerFixture(TimeProvider timeProvider) + private static ServerFixture CreateServerFixture( + TimeProvider timeProvider, + McpClientOptions? clientOptions = null) { var app = ReplApp.Create(); app.UseMcpServer(); @@ -228,7 +234,8 @@ private static ServerFixture CreateServerFixture(TimeProvider timeProvider) var client = SyncWait(McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream()))); + serverToClient.Reader.AsStream()), + clientOptions)); return new ServerFixture(app, options, initialCommand, client, cts, clientToServer, serverToClient, serverTask); } From 3ae3d78c439663f1d3e376533fe80155eb691a96 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 16:05:05 -0400 Subject: [PATCH 07/15] fix(mcp): freeze the channel a presence predicate asks through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery evaluates presence against an empty interaction channel — nothing prefilled, and nobody to elicit or sample from — so a module gated on a confirmation that defaults to true is advertised to every modern connection. Execution overlaid the frozen capability answers but left the live channel in place, so the same predicate read the call's own answer.gate and re-decided the module absent: the advertised tool came back as an unknown command, and a tool argument decided whether the tool it was passed to existed. The channel joins the frozen set, and discovery and execution now build it from one factory so the two views cannot drift apart again. --- docs/mcp-conformance.md | 7 ++- src/Repl.Mcp/McpDiscoveryCapabilities.cs | 22 +++++++- src/Repl.Mcp/McpServerHandler.cs | 8 +-- src/Repl.Mcp/McpToolAdapter.cs | 4 +- .../Given_McpConcurrentSessions.cs | 54 +++++++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 0d4312c8..86e8dc24 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -61,8 +61,10 @@ Two consequences, and they are different problems: The discovery view reaches no live session-scoped service at all, rather than neutralising member by member: `IsSupported`, `HasSoftRoots`, `Current` and `GetAsync` are all connection state, and forwarding any one of them reopens the hole. The frozen set is the four capability services plus -`IReplSessionState` and `IReplSessionInfo` — every session-scoped input a presence predicate can -receive by injection. +`IReplSessionState`, `IReplSessionInfo` and `IReplInteractionChannel` — every session-scoped input a +presence predicate can receive by injection. The channel is in the set because a predicate may *ask*: +the live one answers from the call's own `answer.*` arguments, which would let a tool argument decide +whether the tool it was passed to exists. A predicate that injects an **application** service of its own is outside that set by construction: Repl cannot know which of your singletons is stable and which a command mutates. Gate on something @@ -90,6 +92,7 @@ them against **fixed answers** instead of against the client: | `IsSupported` (roots, sampling, elicitation), `IsLoggingSupported`, `IsProgressSupported` | `true` | | `HasSoftRoots` | `false` | | `Current`, `GetAsync()` | empty | +| A question asked through `IReplInteractionChannel` | its declared default — nothing is prefilled, and there is no client to elicit or sample from | Whatever your predicate returns under those answers is what **every** client is offered. The bucket a command lands in therefore follows the predicate's *result*, not which member it reads — a negated diff --git a/src/Repl.Mcp/McpDiscoveryCapabilities.cs b/src/Repl.Mcp/McpDiscoveryCapabilities.cs index fa7f14ed..966a698e 100644 --- a/src/Repl.Mcp/McpDiscoveryCapabilities.cs +++ b/src/Repl.Mcp/McpDiscoveryCapabilities.cs @@ -96,14 +96,22 @@ public void ClearSoftRoots() /// /// The frozen answers as a set, for the two places that must agree on them. /// + /// How an unanswerable prompt resolves; see . /// /// Discovery decides what is advertised; execution decides whether an advertised command exists. /// Those are the same question, and answering it twice from two different views is what makes a /// tool visible and uncallable. A fresh dictionary per call because the overlay owns what it is /// given. + /// + /// The interaction channel belongs in the set for the same reason the capability services do: a + /// predicate may ask a question, and the live channel answers from the call's own + /// answer.* arguments — which would let a tool argument decide whether the tool it was + /// passed to exists. + /// /// - public static Dictionary CreateSessionScopedOverrides() => new() + public static Dictionary CreateSessionScopedOverrides(InteractivityMode interactivityMode) => new() { + [typeof(IReplInteractionChannel)] = CreateDiscoveryChannel(interactivityMode), [typeof(IMcpClientRoots)] = Roots, [typeof(IMcpSampling)] = Sampling, [typeof(IMcpElicitation)] = Elicitation, @@ -112,6 +120,18 @@ public void ClearSoftRoots() [typeof(IReplSessionInfo)] = SessionInfo, }; + /// + /// The channel a presence predicate is asked through: no prefilled answers, and no client behind + /// it to elicit or sample from, so a question resolves to its declared default. + /// + /// + /// What a question with no default does. It is the host's configured mode rather than a constant + /// so that discovery and execution fail the same way on the same predicate. + /// + /// A fresh channel; it holds no answer, so instances are interchangeable. + public static McpInteractionChannel CreateDiscoveryChannel(InteractivityMode interactivityMode) => + new(new Dictionary(StringComparer.Ordinal), interactivityMode); + private sealed class DiscoverySessionState : IReplSessionState { public bool TryGet(string key, out T? value) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 236f6809..5be80732 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -547,9 +547,8 @@ private McpServiceProviderOverlay CreateDiscoveryServices( { var overlay = new Dictionary { - [typeof(IReplInteractionChannel)] = new McpInteractionChannel( - new Dictionary(StringComparer.Ordinal), - _options.InteractivityMode), + [typeof(IReplInteractionChannel)] = + McpDiscoveryCapabilities.CreateDiscoveryChannel(_options.InteractivityMode), }; if (sessionless) @@ -560,7 +559,8 @@ private McpServiceProviderOverlay CreateDiscoveryServices( // session state is a mutable singleton shared with execution — leaving it live would let a // tools/call decide what the next tools/list advertises. Execution keeps the real services // for binding, and takes these same answers for deciding presence. - foreach (var (type, service) in McpDiscoveryCapabilities.CreateSessionScopedOverrides()) + foreach (var (type, service) in + McpDiscoveryCapabilities.CreateSessionScopedOverrides(_options.InteractivityMode)) { overlay[type] = service; } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 678e3edc..51c1adff 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -254,7 +254,9 @@ private async Task ExecuteThroughPipelineAsync( // the real client and can report what it is missing. A catalog resolved per session has // nothing to reconcile: it was built from the live view and may vary with it. var presenceServices = _catalogIsFrozen - ? new McpServiceProviderOverlay(mcpServices, McpDiscoveryCapabilities.CreateSessionScopedOverrides()) + ? new McpServiceProviderOverlay( + mcpServices, + McpDiscoveryCapabilities.CreateSessionScopedOverrides(_options.InteractivityMode)) : null; var completed = await invocableApp.RunSubInvocationWithOutcomeAsync( effectiveTokens.ToArray(), mcpServices, presenceServices, ct).ConfigureAwait(false); diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index da15ee5d..edeff2ac 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Repl.Interaction; using Repl.Mcp; namespace Repl.McpTests; @@ -339,6 +340,59 @@ await readNewer.Should().ThrowAsync( recoveredOlder.Should().Contain(tool => string.Equals(tool.Name, "added", StringComparison.Ordinal)); } + [TestMethod] + [Description("Regression guard: the interaction channel is a presence input like every other one. Discovery evaluates presence against an empty channel — no prefills, nobody to elicit from — so a module gated on a confirmation that defaults to true is advertised to every modern connection. Execution overlaid the frozen capability answers but left the LIVE channel in place, so the same predicate read the call's own answer.gate and re-decided the module absent: the advertised tool came back as an unknown command, and a tool argument silently decided what existed.")] + public async Task When_AModernClientAnswersAGateItIsGatedOn_Then_ItsAnswerCannotRetractTheTool() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new PromptGatedModule(), (IReplInteractionChannel channel) => AskGate(channel)); + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + var result = await session.Client.CallToolAsync( + toolName: "gated", + arguments: new Dictionary(StringComparer.Ordinal) { ["answer.gate"] = "false" }, + cancellationToken: cts.Token).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Content.OfType().Select(static block => block.Text)); + + text.Should().Contain( + "gate-closed", + because: "the answer belongs to the command that was advertised, not to the decision to advertise it"); + text.Should().NotContain( + "unknown_command", + because: "a tool argument must not be able to retract the tool it was passed to"); + } + + // A presence predicate is synchronous by contract, and this channel answers from prefills or a + // default without ever going async. +#pragma warning disable VSTHRD002 + private static bool AskGate(IReplInteractionChannel channel) => + channel.AskConfirmationAsync( + name: "gate", + prompt: "Expose the gated module?", + defaultValue: true).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + + /// A module gated on the same answer its command accepts. + private sealed class PromptGatedModule : IReplModule + { + public void Map(IReplMap app) => app + .Map("gated", static (IReplInteractionChannel channel) => AskGate(channel) ? "gate-open" : "gate-closed") + .WithAnswer(name: "gate", type: "bool"); + } + /// An app whose module appears only once a command has written the session state. private static ReplApp BuildSessionGatedApp() { From 58dd46c24523350c95fa8eba698f3b58dc689988 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 19 Sep 2026 17:26:35 -0400 Subject: [PATCH 08/15] fix(core): keep a session global explicit through a sub-invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update merges parsed values over the session baseline, but it replaced the explicit-key set with the sub-invocation's own keys — so HasValue denied an option whose value GetValue still returned. A module presence predicate reading HasValue therefore decided differently depending on whether a top-level run or a sub-invocation went last. Under `--env prod mcp serve` that is an advertised tool its own call rejects as unknown: the catalog is built outside any sub-invocation, the call is one, and it reset the set before routing resolved. A top-level run keeps the previous behaviour. It is about to become the baseline itself, and carrying the previous one's keys into SetSessionBaseline is the leak that method exists to prevent. --- docs/mcp-conformance.md | 5 ++- src/Repl.Core/CoreReplApp.Execution.cs | 2 +- .../Parsing/GlobalOptionsSnapshot.cs | 28 +++++++++++-- .../Given_GlobalOptionsAccessor.cs | 28 +++++++++++++ .../Given_McpConcurrentSessions.cs | 39 +++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md index 86e8dc24..08b9834c 100644 --- a/docs/mcp-conformance.md +++ b/docs/mcp-conformance.md @@ -68,7 +68,10 @@ whether the tool it was passed to exists. A predicate that injects an **application** service of its own is outside that set by construction: Repl cannot know which of your singletons is stable and which a command mutates. Gate on something -that does not change, or keep the command mapped unconditionally and fail inside it. +that does not change, or keep the command mapped unconditionally and fail inside it. Framework services +stay Repl's responsibility: a predicate gating on a launch global — `--env prod mcp serve` — reads the +same values, and the same `HasValue`, during a tool call as it did during discovery. A sub-invocation +carries its own tokens but cannot retract what the session provided. What stays allowed is a set that **changes over time** for everyone: `InvalidateRouting()` is application-global, and the resulting `notifications/*/list_changed` reaches every connection with the diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 4e3d7475..15dba158 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -335,7 +335,7 @@ private async ValueTask ExecuteParsedCoreAsync( CancellationToken cancellationToken, IServiceProvider? presenceServiceProvider = null) { - _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations + _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions, preserveSessionExplicitKeys: isSubInvocation); // volatile ref swap — safe under concurrent sub-invocations if (!isSubInvocation) { _globalOptionsSnapshot.SetSessionBaseline(); diff --git a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs index 7884809a..87e1edb8 100644 --- a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs +++ b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs @@ -27,10 +27,32 @@ internal void SetSessionBaseline() _currentValues = baseline; } - internal void Update(IReadOnlyDictionary> parsedValues) + /// The globals this invocation carried on its own tokens. + /// + /// Whether the session's own globals count as explicitly provided. A sub-invocation carries only + /// its own tokens, but the session's values stay in effect — they are merged in below. + /// Explicitness has to travel with them, or denies an option whose value + /// still returns, and a module presence predicate reading it decides + /// differently depending on which invocation ran last. A top-level run passes + /// : it is about to become the baseline itself, and carrying the previous + /// one's keys into is the leak that method exists to prevent. + /// + internal void Update( + IReadOnlyDictionary> parsedValues, + bool preserveSessionExplicitKeys = false) { - _explicitKeys = new HashSet(parsedValues.Keys, StringComparer.OrdinalIgnoreCase); - var merged = new Dictionary>(_sessionBaseline, StringComparer.OrdinalIgnoreCase); + var baseline = _sessionBaseline; + var explicitKeys = new HashSet(parsedValues.Keys, StringComparer.OrdinalIgnoreCase); + if (preserveSessionExplicitKeys) + { + foreach (var key in baseline.Keys) + { + explicitKeys.Add(key); + } + } + + _explicitKeys = explicitKeys; + var merged = new Dictionary>(baseline, StringComparer.OrdinalIgnoreCase); foreach (var (key, value) in parsedValues) { merged[key] = value; diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index b73f0848..f00ec1aa 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -614,6 +614,34 @@ await sut.Core.RunSubInvocationAsync( capturedTenant.Should().Be("acme"); } + [TestMethod] + [Description("Regression guard: a sub-invocation preserved the baseline VALUE but dropped its explicitness. Update merges parsed values over the session baseline, then replaced the explicit-key set with the sub-invocation's own keys — so HasValue denied an option whose value GetValue still returned. A module presence predicate reading HasValue then decided differently depending on whether a top-level run or a sub-invocation went last, which is how an MCP catalog advertises a tool its own execution rejects as unknown.")] + public async Task When_SubInvocationAfterRun_Then_BaselineGlobalOptionsAreStillExplicit() + { + bool? capturedHasTenant = null; + string? capturedTenant = null; + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (TestGlobalOptions opts) => $"{opts.Tenant}"); + sut.Map("check", (IGlobalOptionsAccessor globals) => + { + capturedHasTenant = globals.HasValue("tenant"); + capturedTenant = globals.GetValue("tenant"); + return "ok"; + }); + + // Top-level Run establishes the baseline with --tenant acme. + ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "acme", "--no-logo"])); + + await sut.Core.RunSubInvocationAsync( + ["--no-logo", "check"], sut.Services).ConfigureAwait(false); + + capturedTenant.Should().Be("acme"); + capturedHasTenant.Should().BeTrue( + because: "the value is still in effect, so denying it was provided contradicts GetValue"); + } + [TestMethod] [Description("Sub-invocation does not reset baseline for subsequent sub-invocations.")] public async Task When_MultipleSubInvocations_Then_BaselineRemainsStable() diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index edeff2ac..d017f030 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -393,6 +393,45 @@ public void Map(IReplMap app) => app .WithAnswer(name: "gate", type: "bool"); } + [TestMethod] + [Description("Regression guard: global options are an application-global presence input, and a tool call must not change what they report. Launched as `--env prod mcp serve`, a module gated on the option is advertised; the tool call is a sub-invocation carrying no globals, and it used to reset the explicit-key set, so a predicate reading HasValue re-decided the module absent and the advertised tool came back as an unknown command. The value never moved — only the claim that it had been provided.")] + public async Task When_AModernClientCallsAToolGatedOnALaunchGlobal_Then_TheCommandRuns() + { + var app = ReplApp.Create(); + app.Options(options => options.Parsing.AddGlobalOption("env")); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IGlobalOptionsAccessor globals) => globals.HasValue("env")); + + // Establish the launch baseline the way `--env prod mcp serve` does, without starting a + // second server: the handler below reads the same snapshot that run leaves behind. + await app.RunAsync(["--env", "prod", "always", "--no-logo"]).ConfigureAwait(false); + + var handler = CreateHandlerWithAppServices(app); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var session = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + (await session.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + .Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + var result = await session.Client.CallToolAsync( + toolName: "gated", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + var text = string.Join( + separator: ' ', + values: result.Content.OfType().Select(static block => block.Text)); + + text.Should().Contain( + "roots-only", + because: "the launch global is still in effect, so the advertised command must still exist"); + result.IsError.Should().BeFalse( + because: "a sub-invocation carrying no globals must not retract what the launch provided"); + } + /// An app whose module appears only once a command has written the session state. private static ReplApp BuildSessionGatedApp() { From 920aa1cd96748dd3cc5090df963013202f825425 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 12:47:53 -0400 Subject: [PATCH 09/15] fix(mcp): carry a prompt result whole when feedback rides with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicitly registered prompt handler returns its own GetPromptResult. When a modern request declared no log level, the notices it emitted have nowhere to go but the result, so the wrapper rebuilds it around an extended message list — and the rebuild named Messages and Description only. Every other field the handler set was dropped on the way out, _meta included, for any prompt that reported anything. Without feedback the original result is returned untouched, so the loss appeared only on the path that carries the notice. Rebuilt rather than mutated: a handler is free to hand back an instance it reuses across calls, and appending to that one would make the notice permanent and cumulative. Copying every field is what the rebuild costs, and a second guard reads GetPromptResult's own surface so the next SDK release cannot add a field the wrapper silently drops. The discriminating assertion is the sentinel, not the presence of _meta: the SDK stamps its own serverInfo entry on results, so _meta is never null here. --- docs/mcp-reference.md | 2 +- src/Repl.Mcp/McpExplicitPrompt.cs | 23 ++++++- src/Repl.McpTests/Given_McpUserFeedback.cs | 78 +++++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 7157d3a0..8c51c410 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -524,7 +524,7 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a - Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently at **2.2.0**. The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. - **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) shipped experimentally in the SDK 2.0 preview line and is stable as of 2.2.0; Repl has not adopted it yet. - **Discovery notifications** follow the negotiated revision. Repl drives the SDK's own fan-out rather than broadcasting itself, so an initialize-era client keeps receiving unsolicited `*/list_changed` while a `2026-07-28` client receives only the notification types it requested through `subscriptions/listen`, each tagged with its listen request id (SEP-2575). A modern client that opens no subscription receives none — which is what the specification requires. List results carry `ttlMs: 0`, so such a client re-lists on demand rather than caching. -- **User feedback** (`notice` / `warning` / `problem`, and `IMcpFeedback.SendMessageAsync`) follows the same split. On `2026-07-28`, `logging/setLevel` is gone and a server must not emit `notifications/message` for a request that declared no `_meta/io.modelcontextprotocol/logLevel`. Messages that cannot be delivered as notifications are appended to the **tool result** instead, after the command's own payload, so no host loses them. The exception is a resource read that succeeds: its result is a typed body, so buffered feedback is dropped rather than appended — a read that _fails_ carries it in the surfaced error. Initialize-era clients keep the session-wide `logging/setLevel` behaviour unchanged. Note that the SDK's own client cannot request a level on `2026-07-28` at all, so in practice modern hosts see feedback in the tool result. +- **User feedback** (`notice` / `warning` / `problem`, and `IMcpFeedback.SendMessageAsync`) follows the same split. On `2026-07-28`, `logging/setLevel` is gone and a server must not emit `notifications/message` for a request that declared no `_meta/io.modelcontextprotocol/logLevel`. Messages that cannot be delivered as notifications are appended to the **tool result** instead, after the command's own payload, so no host loses them. Appending is additive: an explicitly registered prompt that returns its own `GetPromptResult` keeps the description and `_meta` it set, alongside the appended notices. The exception is a resource read that succeeds: its result is a typed body, so buffered feedback is dropped rather than appended — a read that _fails_ carries it in the surfaced error. Initialize-era clients keep the session-wide `logging/setLevel` behaviour unchanged. Note that the SDK's own client cannot request a level on `2026-07-28` at all, so in practice modern hosts see feedback in the tool result. - **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72). ### Upgrading from the 1.x SDK diff --git a/src/Repl.Mcp/McpExplicitPrompt.cs b/src/Repl.Mcp/McpExplicitPrompt.cs index d4c24024..0ee0933f 100644 --- a/src/Repl.Mcp/McpExplicitPrompt.cs +++ b/src/Repl.Mcp/McpExplicitPrompt.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -85,6 +85,25 @@ public override async ValueTask GetAsync( }); } - return new GetPromptResult { Messages = messages, Description = result.Description }; + return WithMessages(result, messages); } + + /// Carries the result the handler produced onto an extended message list. + /// + /// Rebuilt rather than mutated, because a handler is free to hand back an instance it reuses + /// across calls — appending to that one would make the notice permanent, and cumulative. Rebuilding + /// in turn means carrying every field the application set: a rebuild that names them by hand drops + /// the ones it forgets in silence, which is how _meta stopped reaching the client. + /// is sealed, so these four are the whole surface, and + /// Given_McpUserFeedback.When_TheSdkPromptResultCarriesAField_Then_TheWrapperCopiesIt goes + /// red if the SDK grows a fifth. + /// + private static GetPromptResult WithMessages(GetPromptResult result, IList messages) => + new() + { + Messages = messages, + Description = result.Description, + Meta = result.Meta, + ResultType = result.ResultType, + }; } diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 1283b509..e5899620 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -1,9 +1,10 @@ -using Repl.Parameters; +using Repl.Parameters; using Microsoft.Extensions.DependencyInjection; using System.IO.Pipelines; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Reflection; using System.Globalization; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -825,6 +826,81 @@ await feedback.SendMessageAsync( } } + [TestMethod] + [Description("Regression guard: appending feedback to an explicitly registered prompt's result must not cost the rest of it. The wrapper rebuilds the result to extend its messages, and a rebuild that names fields by hand drops the ones it forgot without a trace — here _meta, which the handler set and the client was meant to read.")] + public async Task When_AnExplicitPromptReportsBesideMetadata_Then_TheWholeResultSurvives() + { + var session = await McpTestFixture.CreateAsync( + _ => { }, + options => options.Prompt( + "brief", + static async Task (IMcpFeedback feedback, CancellationToken cancellationToken) => + { + await feedback.SendMessageAsync( + McpMessageLevel.Warning, + "prompt-notice", + cancellationToken).ConfigureAwait(false); + + return new GetPromptResult + { + Description = "kept-description", + Meta = new JsonObject { ["sentinel"] = "kept" }, + Messages = + [ + new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = "drafted" }, + }, + ], + }; + })).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + // The precondition the rebuild depends on: without a declared log level there is no + // notification channel, so the notice has to ride in the result and the wrapper runs. + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var result = await session.Client.GetPromptAsync( + "brief", + arguments: null, + cancellationToken: CancellationToken.None).ConfigureAwait(false); + + var text = string.Join( + separator: '\n', + values: result.Messages.Select(static m => (m.Content as TextContentBlock)?.Text ?? string.Empty)); + + text.Should().Contain("drafted", because: "the payload the handler produced comes first"); + text.Should().Contain("prompt-notice", because: "the notice rides after it"); + + result.Description.Should().Be( + "kept-description", + because: "the handler described its own prompt"); + // Read the node out before asserting: a null-conditional chain short-circuits Should() too, + // and the SDK stamps its own serverInfo entry, so a non-null _meta proves nothing here. + var sentinel = result.Meta?["sentinel"]?.GetValue(); + sentinel.Should().Be( + "kept", + because: "only the wrapper stood between the metadata the handler set and the client"); + } + } + + [TestMethod] + [Description("Contract guard on the SDK surface rather than on one result: McpExplicitPrompt rebuilds a GetPromptResult field by field, so a field a later SDK adds would be dropped from every prompt result carrying feedback, and nothing would say so. When this goes red after an SDK bump, copy the new field in the wrapper before widening the list here.")] + public void When_TheSdkPromptResultCarriesAField_Then_TheWrapperCopiesIt() + { + var carried = typeof(GetPromptResult) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(static property => property.CanWrite) + .Select(static property => property.Name) + .Order(StringComparer.Ordinal); + + carried.Should().Equal( + ["Description", "Messages", "Meta", "ResultType"], + because: "McpExplicitPrompt.GetAsync copies exactly these when it appends feedback"); + } + [TestMethod] [Description("Regression guard: an explicitly registered prompt handler must see the connection\u0027s native roots. Every other execution path primes them before the handler runs, so a handler reading Current here would see an empty list and take it for a client that declared no workspace \u2014 a difference it has no way to detect.")] public async Task When_AnExplicitPromptReadsRoots_Then_TheyAreResolved() From ebdd4132a1fc21d1e627ac3973786d1afc5b7e7b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:10:20 -0400 Subject: [PATCH 10/15] fix(core): classify a failed binding callback as an execution error again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReplBindingCallbackException derives from InvalidOperationException, which this pipeline renders as a validation result. So marking what escapes application code during binding — the fix that let an MCP host withhold the cause — moved four shapes of failure into the bucket that tells a caller their input was wrong: an options-group constructor, a property setter, a keyed-service factory, and any DI factory throwing something other than an InvalidOperationException. All four rendered as execution_error before, and nothing declared the change. A dedicated arm ahead of the InvalidOperationException one restores them, and takes the fifth with it: a factory throwing an InvalidOperationException was already classified as validation, by accident of its type. Binding callbacks now answer alike whatever the application threw, which is the same rule this code already applies to cancellation — decide by who failed, not by the type. Validation keeps its meaning. The binder's own diagnostics about the caller's input never travel through the marker, so a missing required parameter is still a validation result. The two existing guards assert the rendered message and both throw an InvalidOperationException, the one type that masks this — they stayed green throughout. The new ones assert the status and the code. --- src/Repl.Core/CoreReplApp.Execution.cs | 13 ++++++- src/Repl.Tests/Given_ExitCodes.cs | 49 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 15dba158..7927cbf1 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; @@ -909,6 +909,17 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); throw; } + // Ahead of the InvalidOperationException arm below, which the marker derives from. A validation + // result says the caller's input was wrong; this one says application code threw while supplying + // a parameter, and the caller has no way to act on it. Classified by WHO failed rather than by + // what type they threw, so a factory, an options-group constructor and a property setter answer + // alike — the binder's own diagnostics about bad input keep the arm below to themselves. + catch (ReplBindingCallbackException ex) + { + return (await RenderFailureAsync( + Results.Error("execution_error", DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken) + .ConfigureAwait(false), false); + } catch (InvalidOperationException ex) { return (await RenderFailureAsync( diff --git a/src/Repl.Tests/Given_ExitCodes.cs b/src/Repl.Tests/Given_ExitCodes.cs index 8915a8b9..50c16ba8 100644 --- a/src/Repl.Tests/Given_ExitCodes.cs +++ b/src/Repl.Tests/Given_ExitCodes.cs @@ -1,4 +1,4 @@ -using AwesomeAssertions; +using AwesomeAssertions; using Microsoft.Extensions.DependencyInjection; namespace Repl.Tests; @@ -1002,6 +1002,53 @@ public async Task When_AnOptionsGroupSetterThrows_Then_TheLocalDiagnosticNamesTh because: "reflection's own wrapper is not the diagnostic, it is what hides it"); } + [TestMethod] + [Description("Regression guard: application code failing while it supplies a parameter is the application failing, not the caller's input being invalid, and the two must not render alike. The marker wrapping those failures derives from InvalidOperationException — the one type this pipeline renders as a validation result — so marking them quietly moved every service factory, options-group constructor and property setter out of execution_error and into the bucket that tells a caller they typed something wrong.")] + public async Task When_ADependencyFactoryThrows_Then_TheOutcomeIsAnExecutionError() + { + var recorder = new OutcomeRecorder(); + var sut = ReplApp.Create(services => services.AddSingleton( + implementationFactory: static _ => throw new IOException("factory-cause-detail"))); + sut.Options(options => + { + options.Interactive.InteractivePolicy = InteractivePolicy.Prevent; + options.Output.BannerEnabled = false; + options.ExitCodes.Resolver = recorder.Record; + }); + sut.Map("work", (IFailingDependency dependency) => dependency.ToString() ?? "ok"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + using var session = OpenSession(out _); + await sut.RunAsync(["work"], cts.Token).ConfigureAwait(false); + + // Not IOE-derived on purpose: an InvalidOperationException from a factory renders as a + // validation result on either side of the marker, so it cannot tell the two apart. + var result = recorder.Last!.Result.Should().BeAssignableTo().Subject; + result.Kind.Should().Be( + "error", + because: "the caller's input was never in question — the application's own factory threw"); + result.Code.Should().Be("execution_error"); + } + + [TestMethod] + [Description("The same rule through reflection: an options-group property setter that throws reaches the binder wrapped in reflection's own exception, which classified as an execution error before the marker existed. Marking it moved it to validation, so two shapes of one cause — a factory and a setter — stopped sharing one classification.")] + public async Task When_AnOptionsGroupSetterThrows_Then_TheOutcomeIsAnExecutionError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("work", (FailingOptions options) => options.Label ?? "ok"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + using var session = OpenSession(out _); + await sut.RunAsync(["work", "--label", "x"], cts.Token).ConfigureAwait(false); + + var result = recorder.Last!.Result.Should().BeAssignableTo().Subject; + result.Kind.Should().Be( + "error", + because: "reflection carrying the failure does not make it the caller's mistake"); + result.Code.Should().Be("execution_error"); + } + [Repl.Parameters.ReplOptionsGroup] public sealed class FailingOptions { From 64c5bef6dcaab4cadf635b231fc62dd6ac76832c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:13:56 -0400 Subject: [PATCH 11/15] fix(mcp): tell a roots budget apart from a caller withdrawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An initialize-era projection awaits the connection's roots fetch, which runs on its own ten-second budget rather than on the caller's token — deliberately, since the result is shared and no single caller may bound it. So the budget expiring surfaces as an OperationCanceledException while the request's own token is still live, and the unfiltered cancellation arm sat above the availability fallback: it rethrew past a catalog this connection had been serving a moment earlier. Reachable whenever roots/list_changed bumps the routing version while the client has stopped answering roots/list. Every other cancellation catch in this package already asks who withdrew rather than reading the exception's type; this one did not. The fallback itself is untouched — still initialize-era, still this connection's own previous catalog, still re-reading the retraction watermark before serving. GetSnapshotAsync sat exactly at the sixty-line cap, so the three failure arms move into their own method rather than the reasoning that separates them being cut to fit. Pure extraction: same order, same filters, same gate around it. --- src/Repl.Mcp/McpServerHandler.cs | 90 ++++++++++++++++---------- src/Repl.McpTests/Given_McpDebounce.cs | 25 ++++++- 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 5be80732..240217a0 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -396,40 +396,8 @@ private async ValueTask GetSnapshotAsync( return refreshed.Snapshot; } - var previousSnapshot = context.SnapshotCache?.Snapshot; - try - { - var built = await BuildCurrentSnapshotAsync(context, snapshotVersion, sessionless, cancellationToken) - .ConfigureAwait(false); - return built; - } - catch (OperationCanceledException) - { - throw; - } - catch (HiddenRequiredOptionException) - { - ThrowSanitizedIfAClientAlreadyHasASchema(previousSnapshot); - throw; - } - catch (Exception) when (IsFallbackEligible(context, sessionless)) - { - // Preserve availability for transient projection failures, but republish as stale so the - // next request retries without requiring another routing mutation. The entry keeps the - // version it was built at, because the retraction comparison reads it: a sentinel version - // would count as older than every retraction and take the fallback away after the first. - // Initialize-era only — see IsFallbackEligible. - // Re-read rather than reuse the filter's value: nothing holds the retraction watermark - // still between the two, and a retraction that lands in between must fail closed with the - // original failure rather than serve a catalog it has just withdrawn. - if (context.SnapshotCache is not { } fallback || !IsFallbackEligible(context, sessionless)) - { - throw; - } - - context.PublishStaleSnapshot(fallback.Snapshot, fallback.Version, fallback.Sessionless); - return fallback.Snapshot; - } + return await BuildOrServePreviousAsync(context, snapshotVersion, sessionless, cancellationToken) + .ConfigureAwait(false); } finally { @@ -437,6 +405,60 @@ private async ValueTask GetSnapshotAsync( } } + /// + /// Builds this request's snapshot, or serves the connection's previous one when the build fails + /// and the era allows it. + /// + /// + /// Split from , which owns the cache fast path and the gate, so the + /// three failure arms keep the reasoning that distinguishes them. + /// + private async ValueTask BuildOrServePreviousAsync( + McpSessionContext context, + long snapshotVersion, + bool sessionless, + CancellationToken cancellationToken) + { + var previousSnapshot = context.SnapshotCache?.Snapshot; + try + { + var built = await BuildCurrentSnapshotAsync(context, snapshotVersion, sessionless, cancellationToken) + .ConfigureAwait(false); + return built; + } + // Filtered on the caller's own token, like every other cancellation catch here: a projection + // awaits the roots fetch, which runs on its own budget rather than the caller's token, so the + // budget expiring arrives as a cancellation nobody asked for. Unfiltered, it rethrew past the + // availability fallback below and took a catalog this connection was serving with it. + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (HiddenRequiredOptionException) + { + ThrowSanitizedIfAClientAlreadyHasASchema(previousSnapshot); + throw; + } + catch (Exception) when (IsFallbackEligible(context, sessionless)) + { + // Preserve availability for transient projection failures, but republish as stale so the + // next request retries without requiring another routing mutation. The entry keeps the + // version it was built at, because the retraction comparison reads it: a sentinel version + // would count as older than every retraction and take the fallback away after the first. + // Initialize-era only — see IsFallbackEligible. + // Re-read rather than reuse the filter's value: nothing holds the retraction watermark + // still between the two, and a retraction that lands in between must fail closed with the + // original failure rather than serve a catalog it has just withdrawn. + if (context.SnapshotCache is not { } fallback || !IsFallbackEligible(context, sessionless)) + { + throw; + } + + context.PublishStaleSnapshot(fallback.Snapshot, fallback.Version, fallback.Sessionless); + return fallback.Snapshot; + } + } + // No snapshot has ever been served: a cold-start configuration error, not a runtime retraction // reaching an already-connected client. Let the caller's rethrow carry the detailed exception so // the operator sees exactly which option and route are misconfigured. diff --git a/src/Repl.McpTests/Given_McpDebounce.cs b/src/Repl.McpTests/Given_McpDebounce.cs index 9ecc8876..84182ba0 100644 --- a/src/Repl.McpTests/Given_McpDebounce.cs +++ b/src/Repl.McpTests/Given_McpDebounce.cs @@ -1,4 +1,4 @@ -using System.IO.Pipelines; +using System.IO.Pipelines; using Microsoft.Extensions.Time.Testing; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -72,6 +72,29 @@ public void When_RebuildThrows_Then_ALegacySessionContinuesWithStaleRoutes() recoveredTools.Should().Contain(tool => string.Equals(tool.Name, "added-after", StringComparison.Ordinal)); } + [TestMethod] + [Description("Regression guard: an availability fallback must not be lost because the failure arrived as a cancellation nobody asked for. The roots fetch a legacy projection awaits runs on its own budget, independent of the caller's token, so the budget expiring surfaces as an OperationCanceledException while the request's own token is still live — and the unfiltered cancellation arm sat above the fallback, rethrowing past a catalog this connection had been serving a moment earlier. Cancellation is told apart by who asked for it, not by the exception's type, which is the rule the roots service and the App resource path already apply.")] + public void When_AProjectionIsCancelledByNobody_Then_ALegacySessionKeepsServingThePreviousCatalog() + { + var fakeTime = new FakeTimeProvider(); + using var fixture = CreateServerFixture(fakeTime, BuildLegacyClientOptions()); + + SyncWait(fixture.Client.ListToolsAsync().AsTask()) + .Should().ContainSingle(tool => string.Equals(tool.Name, "initial", StringComparison.Ordinal)); + + // A foreign, already-cancelled token: the shape the roots budget produces when it expires, on a + // caller whose own token was never touched. + fixture.Options.CommandFilter = _ => throw new OperationCanceledException(new CancellationToken(canceled: true)); + fixture.App.Core.InvalidateRouting(); + fakeTime.Advance(TimeSpan.FromMilliseconds(150)); + + var stale = SyncWait(fixture.Client.ListToolsAsync().AsTask()); + + stale.Should().ContainSingle( + tool => string.Equals(tool.Name, "initial", StringComparison.Ordinal), + because: "this connection had a serve-able catalog and nobody withdrew the request"); + } + [TestMethod] [Description("Regression guard: verifies the availability fallback keeps applying after a visibility retraction. Republishing a served-but-stale snapshot used to overwrite the version it was built at with a zero sentinel, so the retraction watermark was compared against zero and read as older than every retraction ever published. The FIRST failed projection still served the previous catalog and the second surfaced the error instead — a catalog that had been serving a moment earlier became unreachable for as long as the failure lasted. Hiding a command is the retraction: without one the watermark stays at zero, where the sentinel happened to compare equal and the defect is invisible. Initialize-era, because that is the only era the availability fallback applies to.")] public void When_ProjectionKeepsFailingAfterARetraction_Then_ALegacySessionKeepsServingThePreviousCatalog() From f7455ddf5310a1b89650c1cbbb180e95573d988d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:18:09 -0400 Subject: [PATCH 12/15] fix(mcp): release the session registration an invocation minted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every MCP invocation opens a session so the command sees isolated I/O, under an identifier minted for that one call. ReplSessionIO unregisters on dispose only when the caller supplied no identifier, because supplying one is read as owning the lifetime — right for a transport host, which unregisters its own at shutdown, wrong for a throwaway. So every tool call, resource read and prompt get left an entry in a process-wide dictionary that nothing would ever remove, on the one path built to run as a long-lived server. Fixed where the wrong answer came from: ownership is now something a caller can state rather than something inferred from whether it passed an identifier. Inference stays the default, so no existing caller changes. ExecuteThroughPipelineAsync sat at the sixty-line cap, so the overlay's single-entry map loses five lines of braces to make room for the argument. --- src/Repl.Core/Session/ReplSessionIO.cs | 14 +++++++-- src/Repl.Mcp/McpToolAdapter.cs | 9 +++--- .../Given_McpConcurrentSessions.cs | 31 ++++++++++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/Repl.Core/Session/ReplSessionIO.cs b/src/Repl.Core/Session/ReplSessionIO.cs index b8a874bd..d122851b 100644 --- a/src/Repl.Core/Session/ReplSessionIO.cs +++ b/src/Repl.Core/Session/ReplSessionIO.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; namespace Repl; @@ -262,6 +262,13 @@ public static TerminalCapabilities TerminalCapabilities /// Activates a hosted session on the current async context. /// Dispose the returned scope to deactivate. /// + /// + /// removeSessionOnDispose says whether disposing the scope also unregisters the session. + /// Left unset, ownership is inferred: a caller supplying its own sessionId is taken to own + /// the lifetime and unregister it itself, which is what a transport host does at shutdown. State + /// it instead when the identifier is a throwaway minted for one invocation — inference cannot tell + /// the two apart, and guessing wrong leaves an entry nothing will ever remove. + /// public static IDisposable SetSession( TextWriter output, TextReader input, @@ -269,7 +276,8 @@ public static IDisposable SetSession( string? sessionId = null, TextWriter? commandOutput = null, TextWriter? error = null, - bool isHostedSession = true) + bool isHostedSession = true, + bool? removeSessionOnDispose = null) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(input); @@ -323,7 +331,7 @@ public static IDisposable SetSession( previousIsProgrammatic, previousProgrammaticInvocationContractVersion, previousSessionId, - removeSessionOnDispose: string.IsNullOrWhiteSpace(sessionId), + removeSessionOnDispose: removeSessionOnDispose ?? string.IsNullOrWhiteSpace(sessionId), sessionIdToRemove: resolvedSessionId); } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 51c1adff..db257418 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -221,10 +221,7 @@ private async Task ExecuteThroughPipelineAsync( prefills, _options.InteractivityMode, server, progressToken, feedback); var mcpServices = new McpServiceProviderOverlay( _services, - new Dictionary - { - [typeof(IReplInteractionChannel)] = interactionChannel, - }); + new Dictionary { [typeof(IReplInteractionChannel)] = interactionChannel }); var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; using var feedbackScope = feedbackService?.PushProgressToken(progressToken); // Messages the client cannot receive as notifications ride back in the tool result instead, @@ -245,7 +242,9 @@ private async Task ExecuteThroughPipelineAsync( sessionId: $"mcp-{Guid.NewGuid():N}", commandOutput: commandOutput, error: errorWriter, - isHostedSession: true)) + isHostedSession: true, + // Minted for this call alone; inference would read it as a lifetime we own and never release. + removeSessionOnDispose: true)) { ReplSessionIO.IsProgrammatic = true; using var invocationContract = ReplSessionIO.PushProgrammaticInvocationContract(ProgrammaticInvocationContractVersion); diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index d017f030..f97a746c 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -1,4 +1,4 @@ -using System.IO.Pipelines; +using System.IO.Pipelines; using System.Text; using System.Text.Json.Nodes; using ModelContextProtocol; @@ -432,6 +432,35 @@ public async Task When_AModernClientCallsAToolGatedOnALaunchGlobal_Then_TheComma because: "a sub-invocation carrying no globals must not retract what the launch provided"); } + [TestMethod] + [Description("Regression guard: an invocation must release the session registration it minted. Every MCP invocation opens a session under a fresh identifier so the command sees isolated I/O, and ReplSessionIO removes a registration on dispose only when the caller supplied no identifier — the flag means the caller owns the lifetime. That is right for a transport host, which removes its own; here the identifier is a throwaway, so every tool call, resource read and prompt get left an entry in a process-wide dictionary that nothing would ever remove, on the one path built to run as a long-lived server.")] + public async Task When_AToolCallCompletes_Then_ItsSessionRegistrationIsReleased() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + string? observed = null; + var session = await McpTestFixture.CreateAsync( + app => app.Map("work", () => + { + observed = ReplSessionIO.CurrentSessionId; + return "ok"; + })).ConfigureAwait(false); + + await using (session.ConfigureAwait(false)) + { + await session.Client.CallToolAsync( + toolName: "work", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + } + + // Read from inside the command: the identifier is minted per invocation and never surfaces. + var sessionId = observed; + sessionId.Should().NotBeNullOrWhiteSpace( + because: "the command runs inside the session whose lifetime is under test"); + ReplSessionIO.TryGetSession(sessionId ?? string.Empty, out _).Should().BeFalse( + because: "the invocation that minted the identifier is the only thing that can release it"); + } + /// An app whose module appears only once a command has written the session state. private static ReplApp BuildSessionGatedApp() { From 2c7eaf75199c1af8cd250b2b849432944dc47977 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:20:18 -0400 Subject: [PATCH 13/15] docs(mcp): state what the failure split withholds, and where soft roots reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade list counted seven changes and described seven, but the one a consumer is most likely to meet at runtime was not among them: an uncaught handler exception, or an application callback failing while it supplies a parameter, now reaches an MCP client as a generic sentence. The phrase appeared nowhere in docs/ or in the packed README. It is the eighth, with the note a host reading outcomes directly needs — the marker type now travels on the outcome, the application's own exception inside it. The packed README said five things change and then pointed at the reference for the full list, which cannot both be true. It now says what it is: the ones met first, with a pointer to all eight. Two API remarks described a behaviour narrower than the one they have. IsLoggingSupported documents what a request reads and never said a presence predicate reads true regardless — so a module gated on it hides from nobody. SetSoftRoots says "the current session", which on a reused BuildMcpServerOptions result is every connection built from it; the transports page has carried that limitation all along, the API it applies to did not. And GlobalOptionsSnapshot.Update names its callers to explain a choice, but there are three, not two. The interactive resolver takes the same default for a different reason, which is worth stating where the other two are. --- docs/mcp-reference.md | 13 ++++++++++++- src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs | 5 ++++- src/Repl.Mcp/IMcpClientRoots.cs | 11 +++++++++-- src/Repl.Mcp/IMcpFeedback.cs | 8 +++++++- src/Repl.Mcp/README.md | 11 ++++++++--- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 8c51c410..3c556330 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -529,7 +529,7 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a ### Upgrading from the 1.x SDK -Seven things change for an application that already references `Repl.Mcp`. The first two are build +Eight things change for an application that already references `Repl.Mcp`. The first two are build breaks; the rest are behaviour a consumer meets at runtime. **The SDK moves to 2.x.** `ModelContextProtocol` is a transitively public dependency, so a consumer @@ -596,6 +596,17 @@ empty, not that resolving them failed; a client that genuinely answers with zero apart, since that answer counts as resolved. Call `GetAsync` when the difference matters: it resolves on demand and surfaces the failure instead of absorbing it. +**An uncaught exception no longer reaches the client as text.** A command that throws, or an +application callback that fails while supplying a parameter — a service factory, an options-group +constructor, a property setter — is surfaced to an MCP client as `Command failed with exit code N.` +The framework renders that message for an operator at a console, and it routinely carries a path, a +parameter and its CLR type, or a connection string; over MCP the reader is a remote client instead. +Feedback the application itself reported still travels, because the application wrote it for that +reader — so return an error from the command when the client needs to know why. Nothing changes +locally: the console still names the cause. One detail for a host reading outcomes directly, such as +an `ExitCodes.Resolver` — a binding-callback failure now carries `ReplBindingCallbackException` on +`ReplExecutionOutcome.Exception`, with the application's own exception in `InnerException`. + | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| | Tools | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs index 87e1edb8..ea554a92 100644 --- a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs +++ b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl; internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor { @@ -36,6 +36,9 @@ internal void SetSessionBaseline() /// differently depending on which invocation ran last. A top-level run passes /// : it is about to become the baseline itself, and carrying the previous /// one's keys into is the leak that method exists to prevent. + /// The interactive resolver is the third caller and also passes : each + /// committed line is a fresh invocation, so a baseline-only key is in force without having been + /// provided on it — which is what reports. /// internal void Update( IReadOnlyDictionary> parsedValues, diff --git a/src/Repl.Mcp/IMcpClientRoots.cs b/src/Repl.Mcp/IMcpClientRoots.cs index 8b7a9cf0..16216b39 100644 --- a/src/Repl.Mcp/IMcpClientRoots.cs +++ b/src/Repl.Mcp/IMcpClientRoots.cs @@ -1,4 +1,4 @@ -namespace Repl.Mcp; +namespace Repl.Mcp; /// /// Provides access to MCP client roots for the current MCP session. @@ -28,6 +28,11 @@ public interface IMcpClientRoots /// request; soft roots answer only when the client supports no native roots at all. Either way, call /// when the difference matters: it resolves on demand and surfaces a failure /// instead of absorbing it. + /// + /// Soft roots are the exception on that path: they are host-set state with no request to belong to, + /// so every connection built from one BuildMcpServerOptions() result shares the ones any of + /// them set. See the known limitation in docs/mcp-transports.md. + /// /// IReadOnlyList Current { get; } @@ -37,7 +42,9 @@ public interface IMcpClientRoots ValueTask> GetAsync(CancellationToken cancellationToken = default); /// - /// Sets soft roots for the current session. + /// Sets soft roots for the current session — which under mcp serve is the connection, and on + /// a reused BuildMcpServerOptions() result is every connection built from it. See + /// . /// void SetSoftRoots(IEnumerable roots); diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 0484bcf4..2d9237d9 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -1,4 +1,4 @@ -using Repl.Interaction; +using Repl.Interaction; namespace Repl.Mcp; @@ -33,6 +33,12 @@ public interface IMcpFeedback /// dropped — and dropped messages are not carried back in the tool result, because the /// client asked not to receive them. /// + /// + /// A module presence predicate reads instead, on every revision: discovery + /// on 2026-07-28 answers every per-connection question with a constant, and this one is + /// answered as supported. Whatever the predicate decides under that answer is what every client is + /// offered — so gating a module on this member hides it from nobody. + /// /// bool IsLoggingSupported { get; } diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 3f852ea8..56729c29 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -8,10 +8,10 @@ Use `Repl.Mcp` when you already have, or want to build, a Repl command graph and ## Upgrading from a 1.x SDK build -This version builds on `ModelContextProtocol` **2.x**. Five things change for an application already -using `Repl.Mcp`; the repository's +This version builds on `ModelContextProtocol` **2.x**. The changes a consumer meets first are below; +the repository's [MCP reference](https://github.com/yllibed/repl/blob/main/docs/mcp-reference.md#upgrading-from-the-1x-sdk) -carries the full list. +carries all eight. - **The SDK moves to 2.x.** It is a transitively public dependency, so a consumer referencing it directly moves with this package. The 1.x and 2.x assemblies cannot coexist. @@ -20,6 +20,11 @@ carries the full list. - **Tool results can carry extra content blocks.** A message the client could not receive as a notification is appended after the command's payload. The payload stays the first block and `StructuredContent` is untouched, but a test asserting exactly one block will fail. +- **An uncaught exception no longer reaches the client as text.** A command that throws, or an + application callback that fails while supplying a parameter, is surfaced as `Command failed with + exit code N.` — the framework renders that message for an operator, and over MCP the reader is a + remote client. Feedback the application reported itself still travels; return an error from the + command when the client needs the reason. - **`.LongRunning()` no longer advertises task support on the protocol surface**, because SDK 2.x removed the per-tool execution augmentation. The annotation still reaches help and documentation. - **Module presence no longer varies with the client on `2026-07-28`**, which requires the advertised From 2c87d61a4cf207ba8da4312fc93d708acebeb847 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:23:11 -0400 Subject: [PATCH 14/15] refactor(mcp): fold the review nits into one lot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing here changes behaviour; each was raised by a review lens and each is small enough that landing them separately would cost more than it explains. The sessionless discovery overlay built an interaction channel and then let the frozen set overwrite that same key, so a channel and a dictionary were allocated and dropped on every discovery build, and which one won depended on merge order. The frozen set already carries the channel, so on that path it is the overlay. It is handed out as IReadOnlyDictionary now: no caller mutates it. DescribeFailure never returns whitespace, so the non-zero arm of the guard below its call could not run, and it kept a second copy of the withheld sentence in step with the first for no reason. IMcpFeedback was resolved twice per invocation, the second resolve assuming a registration the first had already made. ThrowWithBufferedFeedback returns when nothing is buffered, which is why every call site needs a bare throw after it — it is ThrowIfFeedbackBuffered now, the shape the sibling in McpServerHandler already uses. ISubInvocableReplApp declared a sub-invocation member the outcome overload superseded; no caller reached it through the interface. Its param tags were also ordered differently from the signature. And two pieces of prose: a stray semicolon on its own line, and a clause in McpExplicitPrompt narrating how _meta stopped reaching the client — a state that only ever existed between two commits on this branch, so nothing a reader can reach explains it. --- src/Repl.Core/CoreReplApp.Execution.cs | 6 ----- src/Repl.Core/ISubInvocableReplApp.cs | 11 +++----- src/Repl.Mcp/McpAppResource.cs | 4 +-- src/Repl.Mcp/McpDiscoveryCapabilities.cs | 11 ++++---- src/Repl.Mcp/McpExplicitPrompt.cs | 2 +- src/Repl.Mcp/McpServerHandler.cs | 32 ++++++++++-------------- src/Repl.Mcp/McpSessionContext.cs | 5 ++-- src/Repl.Mcp/McpToolAdapter.cs | 7 +++--- 8 files changed, 29 insertions(+), 49 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 7927cbf1..dfcd7bf8 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -53,12 +53,6 @@ internal ValueTask RunSubInvocationAsync( CancellationToken cancellationToken = default) => ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken); - ValueTask ISubInvocableReplApp.RunSubInvocationAsync( - string[] args, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) => - RunSubInvocationAsync(args, serviceProvider, cancellationToken); - async ValueTask ISubInvocableReplApp.RunSubInvocationWithOutcomeAsync( string[] args, IServiceProvider serviceProvider, diff --git a/src/Repl.Core/ISubInvocableReplApp.cs b/src/Repl.Core/ISubInvocableReplApp.cs index b9807437..2057c926 100644 --- a/src/Repl.Core/ISubInvocableReplApp.cs +++ b/src/Repl.Core/ISubInvocableReplApp.cs @@ -1,22 +1,17 @@ -namespace Repl; +namespace Repl; internal interface ISubInvocableReplApp { - ValueTask RunSubInvocationAsync( - string[] args, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default); - /// - /// As , and also reports how the run ended. + /// Runs a nested invocation against the host's own command graph, and reports how it ended. /// /// Command-line tokens for the sub-invocation. /// Resolves handler arguments. - /// Cancels the run. /// /// Decides module presence, when that must not be decided from — /// a host that already published a catalog has to run the command the catalog promised. /// + /// Cancels the run. ValueTask RunSubInvocationWithOutcomeAsync( string[] args, IServiceProvider serviceProvider, diff --git a/src/Repl.Mcp/McpAppResource.cs b/src/Repl.Mcp/McpAppResource.cs index 08a38af0..015e9c62 100644 --- a/src/Repl.Mcp/McpAppResource.cs +++ b/src/Repl.Mcp/McpAppResource.cs @@ -57,7 +57,7 @@ public override bool IsMatch(string uri) => /// failure without feedback would explain itself, which no caller could account for. /// /// - private static void ThrowWithBufferedFeedback( + private static void ThrowIfFeedbackBuffered( Exception exception, McpFeedbackService.UndeliveredMessageScope undelivered) { @@ -107,7 +107,7 @@ public override async ValueTask ReadAsync( catch (Exception exception) when (undelivered is not null && (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)) { - ThrowWithBufferedFeedback(exception, undelivered); + ThrowIfFeedbackBuffered(exception, undelivered); throw; } diff --git a/src/Repl.Mcp/McpDiscoveryCapabilities.cs b/src/Repl.Mcp/McpDiscoveryCapabilities.cs index 966a698e..27971c8a 100644 --- a/src/Repl.Mcp/McpDiscoveryCapabilities.cs +++ b/src/Repl.Mcp/McpDiscoveryCapabilities.cs @@ -1,4 +1,4 @@ -using Repl.Interaction; +using Repl.Interaction; using Repl.Terminal; namespace Repl.Mcp; @@ -98,10 +98,8 @@ public void ClearSoftRoots() /// /// How an unanswerable prompt resolves; see . /// - /// Discovery decides what is advertised; execution decides whether an advertised command exists. - /// Those are the same question, and answering it twice from two different views is what makes a - /// tool visible and uncallable. A fresh dictionary per call because the overlay owns what it is - /// given. + /// Both views must answer alike, for the reason the type remarks give. A fresh dictionary per call, + /// because the overlay owns what it is given. /// /// The interaction channel belongs in the set for the same reason the capability services do: a /// predicate may ask a question, and the live channel answers from the call's own @@ -109,7 +107,8 @@ public void ClearSoftRoots() /// passed to exists. /// /// - public static Dictionary CreateSessionScopedOverrides(InteractivityMode interactivityMode) => new() + public static IReadOnlyDictionary CreateSessionScopedOverrides( + InteractivityMode interactivityMode) => new Dictionary { [typeof(IReplInteractionChannel)] = CreateDiscoveryChannel(interactivityMode), [typeof(IMcpClientRoots)] = Roots, diff --git a/src/Repl.Mcp/McpExplicitPrompt.cs b/src/Repl.Mcp/McpExplicitPrompt.cs index 0ee0933f..3f57e2d3 100644 --- a/src/Repl.Mcp/McpExplicitPrompt.cs +++ b/src/Repl.Mcp/McpExplicitPrompt.cs @@ -93,7 +93,7 @@ public override async ValueTask GetAsync( /// Rebuilt rather than mutated, because a handler is free to hand back an instance it reuses /// across calls — appending to that one would make the notice permanent, and cumulative. Rebuilding /// in turn means carrying every field the application set: a rebuild that names them by hand drops - /// the ones it forgets in silence, which is how _meta stopped reaching the client. + /// the ones it forgets in silence. /// is sealed, so these four are the whole surface, and /// Given_McpUserFeedback.When_TheSdkPromptResultCarriesAField_Then_TheWrapperCopiesIt goes /// red if the SDK grows a fifth. diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 240217a0..3c0f8212 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -567,26 +567,20 @@ private McpServiceProviderOverlay CreateDiscoveryServices( IServiceProvider sessionServices, bool sessionless) { - var overlay = new Dictionary - { - [typeof(IReplInteractionChannel)] = - McpDiscoveryCapabilities.CreateDiscoveryChannel(_options.InteractivityMode), - }; - - if (sessionless) - { - // On a modern revision the advertised set must not vary per connection nor as a side effect - // of another request, so discovery sees constants that reach no live service at all. It is - // not only the capability services: a presence predicate receives whatever it declares, and - // session state is a mutable singleton shared with execution — leaving it live would let a - // tools/call decide what the next tools/list advertises. Execution keeps the real services - // for binding, and takes these same answers for deciding presence. - foreach (var (type, service) in - McpDiscoveryCapabilities.CreateSessionScopedOverrides(_options.InteractivityMode)) + // On a modern revision the advertised set must not vary per connection nor as a side effect of + // another request, so discovery sees constants that reach no live service at all. It is not only + // the capability services: a presence predicate receives whatever it declares, and session state + // is a mutable singleton shared with execution — leaving it live would let a tools/call decide + // what the next tools/list advertises. Execution keeps the real services for binding, and takes + // these same answers for deciding presence. The frozen set carries the channel too, so there it + // is the whole overlay; elsewhere the channel alone is overlaid and the rest stays live. + IReadOnlyDictionary overlay = sessionless + ? McpDiscoveryCapabilities.CreateSessionScopedOverrides(_options.InteractivityMode) + : new Dictionary { - overlay[type] = service; - } - } + [typeof(IReplInteractionChannel)] = + McpDiscoveryCapabilities.CreateDiscoveryChannel(_options.InteractivityMode), + }; return new McpServiceProviderOverlay(sessionServices, overlay); } diff --git a/src/Repl.Mcp/McpSessionContext.cs b/src/Repl.Mcp/McpSessionContext.cs index 948431b3..6e10ab8e 100644 --- a/src/Repl.Mcp/McpSessionContext.cs +++ b/src/Repl.Mcp/McpSessionContext.cs @@ -1,4 +1,4 @@ -namespace Repl.Mcp; +namespace Repl.Mcp; /// /// State owned by one MCP transport session, or — on the reusable-options path — by the handler @@ -99,6 +99,5 @@ internal sealed record SnapshotCacheEntry( McpServerHandler.McpGeneratedSnapshot Snapshot, long Version, bool IsStale, - bool Sessionless) -; + bool Sessionless); } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index db257418..756f57bc 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -149,9 +149,8 @@ public async Task InvokeAsync( var output = invocation.ExitCode == 0 ? invocation.Output : DescribeFailure(invocation); if (string.IsNullOrWhiteSpace(output)) { - output = invocation.ExitCode == 0 - ? "OK" - : $"Command failed with exit code {invocation.ExitCode}."; + // Only a success reaches here: DescribeFailure names the exit code when it has nothing else. + output = "OK"; } return BuildToolResult( @@ -222,7 +221,7 @@ private async Task ExecuteThroughPipelineAsync( var mcpServices = new McpServiceProviderOverlay( _services, new Dictionary { [typeof(IReplInteractionChannel)] = interactionChannel }); - var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; + var feedbackService = feedback as McpFeedbackService; using var feedbackScope = feedbackService?.PushProgressToken(progressToken); // Messages the client cannot receive as notifications ride back in the tool result instead, // so no feedback is lost on a request that never asked for log notifications. From a817963687c1274f28947e749b562d25b823ff35 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 20 Sep 2026 21:57:33 -0400 Subject: [PATCH 15/15] test(core): pin both halves of the cancellation-provenance rule at the binder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review raised that the marker's who-cancelled test reads the exception reflection handed it, and reflection wraps what a setter, a constructor or a keyed factory threw. That is true — measured, the setter surfaces a TargetInvocationException — but the consequence it predicted does not follow, and neither direction was covered. Measured on both sides. A caller that withdraws mid-binding ends the run Cancelled with no error result: the answer is reached at the pipeline boundary, on the caller's own token, rather than at the marker. A callback that gives up on a budget of its own, while the caller's token is live, is marked and rendered execution_error, which is the application failing like any other. Both are now guards rather than readings, so a later change to either the marker or the boundary cannot quietly turn a withdrawal into a failure report. --- src/Repl.Tests/Given_ExitCodes.cs | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/Repl.Tests/Given_ExitCodes.cs b/src/Repl.Tests/Given_ExitCodes.cs index 50c16ba8..99d91225 100644 --- a/src/Repl.Tests/Given_ExitCodes.cs +++ b/src/Repl.Tests/Given_ExitCodes.cs @@ -1049,6 +1049,92 @@ public async Task When_AnOptionsGroupSetterThrows_Then_TheOutcomeIsAnExecutionEr result.Code.Should().Be("execution_error"); } + [TestMethod] + [Description("Regression guard: a callback that cancels on a token of its own, while the caller's is still live, has failed like any other and must be classified as one. Reflection wraps what a setter throws in its own exception, so the marker's who-cancelled test never sees the OperationCanceledException underneath — it marks, which is the right answer here and is worth pinning, because the same blindness is what a reviewer reads as a withdrawal being mislabelled.")] + public async Task When_AnOptionsGroupSetterCancelsItself_Then_TheOutcomeIsAnExecutionError() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("work", (SelfCancellingOptions options) => options.Label ?? "ok"); + + using var session = OpenSession(out _); + await sut.RunAsync(["work", "--label", "x"], cts.Token).ConfigureAwait(false); + + cts.IsCancellationRequested.Should().BeFalse(because: "nobody asked this run to stop"); + var result = recorder.Last!.Result.Should().BeAssignableTo().Subject; + result.Kind.Should().Be("error"); + result.Code.Should().Be( + "execution_error", + because: "a callback giving up on its own budget is the application failing"); + } + + [TestMethod] + [Description("And the converse: when the caller is the one who withdrew, the run stops rather than reporting a failure — even though the withdrawal reaches the binder through reflection's wrapper, which the marker's test does not look through. The answer is reached at the pipeline boundary, on the caller's own token, rather than at the marker; this pins the behaviour so a later change to either one cannot quietly turn a withdrawal into an execution error.")] + public async Task When_TheCallerWithdrawsDuringAnOptionsGroupSetter_Then_TheRunIsCancelled() + { + using var cts = new CancellationTokenSource(); + WithdrawingOptions.Withdrawal = cts; + try + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("work", (WithdrawingOptions options) => options.Label ?? "ok"); + + using var session = OpenSession(out _); + await sut.RunAsync(["work", "--label", "x"], cts.Token).ConfigureAwait(false); + + recorder.Last!.Kind.Should().Be( + ReplExecutionOutcomeKind.Cancelled, + because: "the caller withdrew, and application code obliged"); + } + finally + { + WithdrawingOptions.Withdrawal = null; + } + } + + /// A setter that gives up on its own, the way a callback running its own budget would. + [Repl.Parameters.ReplOptionsGroup] + public sealed class SelfCancellingOptions + { + private string? _label; + + public string? Label + { + get => _label; + set + { + _label = value; + throw new OperationCanceledException(new CancellationToken(canceled: true)); + } + } + } + + /// A setter that observes the caller withdrawing and stops. + [Repl.Parameters.ReplOptionsGroup] + public sealed class WithdrawingOptions + { + internal static CancellationTokenSource? Withdrawal; + + private string? _label; + + public string? Label + { + get => _label; + set + { + _label = value; + if (Withdrawal is not { } withdrawal) + { + return; + } + + withdrawal.Cancel(); + throw new OperationCanceledException(withdrawal.Token); + } + } + } [Repl.Parameters.ReplOptionsGroup] public sealed class FailingOptions {