From 205ed72e4b8bdabd2fe0432d6e44ca26d5cbfd60 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 09:46:02 -0400 Subject: [PATCH 1/8] Add MCP roots and dynamic tool compatibility --- src/Repl.Core/CoreReplApp.Documentation.cs | 13 +- .../Internal/Options/OptionSchemaBuilder.cs | 3 +- src/Repl.Mcp/DynamicToolCompatibilityMode.cs | 18 + src/Repl.Mcp/IMcpClientRoots.cs | 38 + src/Repl.Mcp/McpClientRoot.cs | 8 + src/Repl.Mcp/McpClientRootsService.cs | 158 ++++ src/Repl.Mcp/McpJsonContext.cs | 2 + src/Repl.Mcp/McpReplExtensions.cs | 16 +- src/Repl.Mcp/McpServerHandler.cs | 759 +++++++++++++----- src/Repl.Mcp/McpServiceProviderOverlay.cs | 10 +- src/Repl.Mcp/McpToolAdapter.cs | 8 +- src/Repl.Mcp/ReplMcpServerOptions.cs | 6 + src/Repl.McpTests/Given_McpFallbackOptions.cs | 12 +- .../Given_McpRootsAndDynamicTools.cs | 187 +++++ .../Given_McpServerOptionsBuilder.cs | 17 +- src/Repl.McpTests/McpTestFixture.cs | 14 +- 16 files changed, 1009 insertions(+), 260 deletions(-) create mode 100644 src/Repl.Mcp/DynamicToolCompatibilityMode.cs create mode 100644 src/Repl.Mcp/IMcpClientRoots.cs create mode 100644 src/Repl.Mcp/McpClientRoot.cs create mode 100644 src/Repl.Mcp/McpClientRootsService.cs create mode 100644 src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index ad36897..56dc267 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -57,6 +57,16 @@ public ReplDocumentationModel CreateDocumentationModel(string? targetPath = null Resources: resourceDocs); } + internal ReplDocumentationModel CreateDocumentationModel( + IServiceProvider serviceProvider, + string? targetPath = null) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); + return CreateDocumentationModel(targetPath); + } + /// /// Internal documentation model creation that supports not-found result for help rendering. /// @@ -285,7 +295,8 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(IReplSessionState) || parameterType == typeof(IReplInteractionChannel) || parameterType == typeof(IReplIoContext) - || parameterType == typeof(IReplKeyReader); + || parameterType == typeof(IReplKeyReader) + || string.Equals(parameterType.FullName, "Repl.IMcpClientRoots", StringComparison.Ordinal); private static bool IsRequiredParameter(ParameterInfo parameter) { diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 77a7ca6..fd713e7 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -197,7 +197,8 @@ private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) => || parameter.ParameterType == typeof(IReplSessionState) || parameter.ParameterType == typeof(IReplInteractionChannel) || parameter.ParameterType == typeof(IReplIoContext) - || parameter.ParameterType == typeof(IReplKeyReader); + || parameter.ParameterType == typeof(IReplKeyReader) + || string.Equals(parameter.ParameterType.FullName, "Repl.IMcpClientRoots", StringComparison.Ordinal); private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) { diff --git a/src/Repl.Mcp/DynamicToolCompatibilityMode.cs b/src/Repl.Mcp/DynamicToolCompatibilityMode.cs new file mode 100644 index 0000000..a1182fc --- /dev/null +++ b/src/Repl.Mcp/DynamicToolCompatibilityMode.cs @@ -0,0 +1,18 @@ +namespace Repl; + +/// +/// Controls the compatibility strategy used for dynamic MCP tool lists. +/// +public enum DynamicToolCompatibilityMode +{ + /// + /// Exposes the real tool list directly and relies on standard MCP list_changed notifications. + /// + Disabled = 0, + + /// + /// Exposes a bootstrap discover_tools / call_tool pair on the first tools/list + /// response, then asks the client to refresh so it can see the real tool list. + /// + DiscoverAndCallShim = 1, +} diff --git a/src/Repl.Mcp/IMcpClientRoots.cs b/src/Repl.Mcp/IMcpClientRoots.cs new file mode 100644 index 0000000..fca7167 --- /dev/null +++ b/src/Repl.Mcp/IMcpClientRoots.cs @@ -0,0 +1,38 @@ +namespace Repl; + +/// +/// Provides access to MCP client roots for the current MCP session. +/// +public interface IMcpClientRoots +{ + /// + /// Gets a value that indicates whether the connected MCP client supports native roots discovery. + /// + bool IsSupported { get; } + + /// + /// Gets a value that indicates whether soft roots were configured for the current session. + /// + bool HasSoftRoots { get; } + + /// + /// Gets the current effective roots for the session. + /// Native roots are preferred when supported; otherwise soft roots are returned. + /// + IReadOnlyList Current { get; } + + /// + /// Resolves the current effective roots for the session, refreshing native roots on demand when supported. + /// + ValueTask> GetAsync(CancellationToken cancellationToken = default); + + /// + /// Sets soft roots for the current session. + /// + void SetSoftRoots(IEnumerable roots); + + /// + /// Clears the soft roots for the current session. + /// + void ClearSoftRoots(); +} diff --git a/src/Repl.Mcp/McpClientRoot.cs b/src/Repl.Mcp/McpClientRoot.cs new file mode 100644 index 0000000..278906d --- /dev/null +++ b/src/Repl.Mcp/McpClientRoot.cs @@ -0,0 +1,8 @@ +namespace Repl; + +/// +/// Describes an MCP client root. +/// +/// Root URI. +/// Optional display name. +public sealed record McpClientRoot(Uri Uri, string? Name = null); diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs new file mode 100644 index 0000000..2522d64 --- /dev/null +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -0,0 +1,158 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +internal sealed class McpClientRootsService : IMcpClientRoots +{ + private readonly ICoreReplApp _app; + private readonly Lock _syncRoot = new(); + private McpServer? _server; + private McpClientRoot[] _hardRoots = []; + private McpClientRoot[] _softRoots = []; + private bool _hardRootsLoaded; + + public McpClientRootsService(ICoreReplApp app) + { + _app = app; + } + + public bool IsSupported => _server?.ClientCapabilities?.Roots is not null; + + public bool HasSoftRoots + { + get + { + lock (_syncRoot) + { + return _softRoots.Length > 0; + } + } + } + + public IReadOnlyList Current + { + get + { + lock (_syncRoot) + { + return IsSupported ? _hardRoots : _softRoots; + } + } + } + + public void AttachServer(McpServer server) + { + ArgumentNullException.ThrowIfNull(server); + _server = server; + } + + public async ValueTask> GetAsync(CancellationToken cancellationToken = default) + { + var server = _server; + if (server?.ClientCapabilities?.Roots is null) + { + return Current; + } + + lock (_syncRoot) + { + if (_hardRootsLoaded) + { + return _hardRoots; + } + } + + var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken) + .ConfigureAwait(false); + var mappedRoots = result.Roots?.Select(MapRoot).ToArray() ?? []; + + lock (_syncRoot) + { + _hardRoots = mappedRoots; + _hardRootsLoaded = true; + return _hardRoots; + } + } + + public void SetSoftRoots(IEnumerable roots) + { + ArgumentNullException.ThrowIfNull(roots); + + var normalized = roots.ToArray(); + var changed = false; + lock (_syncRoot) + { + if (!AreEqual(_softRoots, normalized)) + { + _softRoots = normalized; + changed = true; + } + } + + if (changed) + { + _app.InvalidateRouting(); + } + } + + public void ClearSoftRoots() + { + var changed = false; + lock (_syncRoot) + { + if (_softRoots.Length > 0) + { + _softRoots = []; + changed = true; + } + } + + if (changed) + { + _app.InvalidateRouting(); + } + } + + public void HandleRootsListChanged() + { + lock (_syncRoot) + { + _hardRoots = []; + _hardRootsLoaded = false; + } + + _app.InvalidateRouting(); + } + + private static McpClientRoot MapRoot(Root root) + { + var uri = Uri.TryCreate(root.Uri, UriKind.Absolute, out var parsed) + ? parsed + : new Uri(root.Uri, UriKind.RelativeOrAbsolute); + return new McpClientRoot(uri, root.Name); + } + + private static bool AreEqual(McpClientRoot[] left, McpClientRoot[] right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (left[i] != right[i]) + { + return false; + } + } + + return true; + } +} diff --git a/src/Repl.Mcp/McpJsonContext.cs b/src/Repl.Mcp/McpJsonContext.cs index 615de79..50447e3 100644 --- a/src/Repl.Mcp/McpJsonContext.cs +++ b/src/Repl.Mcp/McpJsonContext.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; namespace Repl.Mcp; @@ -7,6 +8,7 @@ namespace Repl.Mcp; /// Source-generated JSON serialization context for trim-safe serialization. /// [JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(Tool[]))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(bool))] internal sealed partial class McpJsonContext : JsonSerializerContext; diff --git a/src/Repl.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index 5307afd..b1e2ffb 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -66,20 +66,8 @@ public static McpServerOptions BuildMcpServerOptions( var options = new ReplMcpServerOptions(); configure?.Invoke(options); - var previousProgrammatic = ReplSessionIO.IsProgrammatic; - ReplSessionIO.IsProgrammatic = true; - try - { - var model = app.CreateDocumentationModel(); - var adapter = new McpToolAdapter(app, options, services ?? EmptyServiceProvider.Instance); - var separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); - var handler = new McpServerHandler(app, options, services ?? EmptyServiceProvider.Instance); - return handler.BuildServerOptions(model, adapter, separator); - } - finally - { - ReplSessionIO.IsProgrammatic = previousProgrammatic; - } + var handler = new McpServerHandler(app, options, services ?? EmptyServiceProvider.Instance); + return handler.BuildServerOptions(); } private sealed class EmptyServiceProvider : IServiceProvider diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 8c5ba39..a76812e 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -1,4 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -12,10 +14,31 @@ namespace Repl.Mcp; /// internal sealed class McpServerHandler { + private const string DiscoverToolsName = "discover_tools"; + private const string CallToolName = "call_tool"; + private static readonly IDictionary EmptyArguments = + new Dictionary(StringComparer.Ordinal); + private static readonly string[] CompatibilityCallRequiredProperties = ["name"]; + private readonly ICoreReplApp _app; private readonly ReplMcpServerOptions _options; private readonly IServiceProvider _services; private readonly TimeProvider _timeProvider; + private readonly char _separator; + private readonly McpClientRootsService _roots; + private readonly IServiceProvider _sessionServices; + private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); + private readonly Lock _refreshLock = new(); + private readonly Lock _attachLock = new(); + + private McpGeneratedSnapshot? _snapshot; + private bool _snapshotDirty = true; + private McpServer? _server; + private EventHandler? _routingChangedHandler; + private ITimer? _debounceTimer; + private int _rootsNotificationRegistered; + private int _compatibilityIntroServed; + private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); public McpServerHandler( ICoreReplApp app, @@ -26,6 +49,14 @@ public McpServerHandler( _options = options; _services = services; _timeProvider = services.GetService(typeof(TimeProvider)) as TimeProvider ?? TimeProvider.System; + _separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); + _roots = new McpClientRootsService(app); + _sessionServices = new McpServiceProviderOverlay( + services, + new Dictionary + { + [typeof(IMcpClientRoots)] = _roots, + }); } [UnconditionalSuppressMessage( @@ -34,74 +65,540 @@ public McpServerHandler( Justification = "MCP server handler runs in a context where all types are preserved.")] public async Task RunAsync(IReplIoContext io, CancellationToken ct) { - // Build the doc model with Programmatic channel so module presence - // predicates see the same channel as tool dispatch. - var previousProgrammatic = ReplSessionIO.IsProgrammatic; - ReplSessionIO.IsProgrammatic = true; + var serverOptions = BuildServerOptions(); + var serverName = serverOptions.ServerInfo?.Name ?? "repl-mcp-server"; + var transport = _options.TransportFactory is { } factory + ? factory(serverName, io) + : new StdioServerTransport(serverName); try { - var model = _app.CreateDocumentationModel(); - var adapter = new McpToolAdapter(_app, _options, _services); - var separator = McpToolNameFlattener.ResolveSeparator(_options.ToolNamingSeparator); - var serverOptions = BuildServerOptions(model, adapter, separator); + var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); + AttachServer(server); - var serverName = serverOptions.ServerInfo?.Name ?? "repl-mcp-server"; - var transport = _options.TransportFactory is { } factory - ? factory(serverName, io) - : new StdioServerTransport(serverName); try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _services); - try - { - SubscribeToRoutingChanges( - adapter, separator, - serverOptions.ToolCollection!, - serverOptions.ResourceCollection!, - serverOptions.PromptCollection!); - await server.RunAsync(ct).ConfigureAwait(false); - } - finally - { - UnsubscribeFromRoutingChanges(); - await server.DisposeAsync().ConfigureAwait(false); - } + await server.RunAsync(ct).ConfigureAwait(false); } finally { - await transport.DisposeAsync().ConfigureAwait(false); + UnsubscribeFromRoutingChanges(); + await server.DisposeAsync().ConfigureAwait(false); } } finally { - ReplSessionIO.IsProgrammatic = previousProgrammatic; + await transport.DisposeAsync().ConfigureAwait(false); } } - internal McpServerOptions BuildServerOptions( - ReplDocumentationModel model, - McpToolAdapter adapter, - char separator) + internal McpServerOptions BuildServerOptions() { - var commandsByPath = model.Commands.ToDictionary( - c => c.Path, c => c, StringComparer.OrdinalIgnoreCase); - var tools = GenerateAllTools(model, adapter, separator, commandsByPath); - var resources = GenerateResources(model, adapter, separator, commandsByPath); - var prompts = CollectPrompts(model, adapter, separator); - - var serverName = _options.ServerName ?? model.App.Name ?? "repl-mcp-server"; + var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; var serverVersion = _options.ServerVersion ?? "1.0.0"; return new McpServerOptions { ServerInfo = new Implementation { Name = serverName, Version = serverVersion }, Capabilities = BuildCapabilities(), - ToolCollection = ToCollection(tools), - ResourceCollection = ToResourceCollection(resources), - PromptCollection = ToCollection(prompts), + Handlers = new McpServerHandlers + { + ListToolsHandler = ListToolsAsync, + CallToolHandler = CallToolAsync, + ListResourcesHandler = ListResourcesAsync, + ListResourceTemplatesHandler = ListResourceTemplatesAsync, + ReadResourceHandler = ReadResourceAsync, + ListPromptsHandler = ListPromptsAsync, + GetPromptHandler = GetPromptAsync, + }, + }; + } + + internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); + + internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => + await GetSnapshotAsync(server: null, cancellationToken).ConfigureAwait(false); + + private string? ResolveAppName() + { + var previousProgrammatic = ReplSessionIO.IsProgrammatic; + ReplSessionIO.IsProgrammatic = true; + try + { + var model = CreateDocumentationModel(); + return model.App.Name; + } + finally + { + ReplSessionIO.IsProgrammatic = previousProgrammatic; + } + } + + private async ValueTask ListToolsAsync( + RequestContext request, + CancellationToken cancellationToken) + { + AttachServer(request.Server); + var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + + if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim + && Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0) + { + _ = SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification); + return new ListToolsResult + { + Tools = + [ + CreateCompatibilityDiscoverTool(), + CreateCompatibilityCallTool(), + ], + }; + } + + return 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); + IDictionary arguments = request.Params?.Arguments ?? EmptyArguments; + var toolName = request.Params?.Name ?? string.Empty; + var progressToken = request.Params?.ProgressToken; + + if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) + { + if (string.Equals(toolName, DiscoverToolsName, StringComparison.Ordinal)) + { + return BuildDiscoverToolsResult(snapshot); + } + + if (string.Equals(toolName, CallToolName, StringComparison.Ordinal)) + { + return await InvokeCompatibilityToolAsync(snapshot, arguments, request.Server, progressToken, cancellationToken) + .ConfigureAwait(false); + } + } + + return await snapshot.Adapter.InvokeAsync( + toolName, + arguments, + request.Server, + progressToken, + cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ListResourcesAsync( + RequestContext request, + CancellationToken cancellationToken) + { + AttachServer(request.Server); + var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + return new ListResourcesResult + { + Resources = + [ + .. 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 + { + ResourceTemplates = + [ + .. 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); + var uri = request.Params?.Uri ?? string.Empty; + var resource = snapshot.Resources.FirstOrDefault(candidate => candidate.IsMatch(uri)); + if (resource is null) + { + throw new McpException($"Unknown resource: {uri}"); + } + + return await resource.ReadAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ListPromptsAsync( + RequestContext request, + CancellationToken cancellationToken) + { + AttachServer(request.Server); + var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + return 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); + var promptName = request.Params?.Name ?? string.Empty; + var prompt = snapshot.Prompts.FirstOrDefault(candidate => + string.Equals(candidate.ProtocolPrompt.Name, promptName, StringComparison.OrdinalIgnoreCase)); + if (prompt is null) + { + throw new McpException($"Unknown prompt: {promptName}"); + } + + return await prompt.GetAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask GetSnapshotAsync( + McpServer? server, + CancellationToken cancellationToken) + { + AttachServer(server); + + if (!_snapshotDirty && _snapshot is { } cached) + { + return cached; + } + + await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_snapshotDirty && _snapshot is { } refreshed) + { + return refreshed; + } + + var previousSnapshot = _snapshot; + try + { + await _roots.GetAsync(cancellationToken).ConfigureAwait(false); + var built = BuildSnapshotCore(); + _snapshot = built; + _snapshotDirty = false; + return built; + } + catch when (previousSnapshot is not null) + { + _snapshot = previousSnapshot; + _snapshotDirty = false; + return previousSnapshot; + } + } + finally + { + _snapshotGate.Release(); + } + } + + private McpGeneratedSnapshot BuildSnapshotCore() + { + var model = CreateDocumentationModel(); + var adapter = new McpToolAdapter(_app, _options, _sessionServices); + 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); + return new McpGeneratedSnapshot(adapter, tools, resources, prompts); + } + + private ReplDocumentationModel CreateDocumentationModel() + { + var coreApp = _app as CoreReplApp + ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); + + var previousProgrammatic = ReplSessionIO.IsProgrammatic; + ReplSessionIO.IsProgrammatic = true; + try + { + return coreApp.CreateDocumentationModel(_sessionServices); + } + finally + { + ReplSessionIO.IsProgrammatic = previousProgrammatic; + } + } + + private void ValidateCompatibilityToolNames(IReadOnlyList tools) + { + if (_options.DynamicToolCompatibility != DynamicToolCompatibilityMode.DiscoverAndCallShim) + { + return; + } + + foreach (var tool in tools) + { + var name = tool.ProtocolTool.Name; + if (string.Equals(name, DiscoverToolsName, StringComparison.OrdinalIgnoreCase) + || string.Equals(name, CallToolName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"MCP tool name collision: '{name}' is reserved by DynamicToolCompatibility mode."); + } + } + } + + private void AttachServer(McpServer? server) + { + if (server is null) + { + return; + } + + lock (_attachLock) + { + if (ReferenceEquals(_server, server)) + { + return; + } + + _server = server; + _roots.AttachServer(server); + EnsureRoutingSubscription(); + EnsureRootsNotificationHandler(server); + } + } + + private void EnsureRoutingSubscription() + { + if (_routingChangedHandler is not null || _app is not CoreReplApp coreApp) + { + return; + } + + var weakSelf = new WeakReference(this); + EventHandler? handler = null; + handler = (_, _) => + { + if (!weakSelf.TryGetTarget(out var target)) + { + coreApp.RoutingInvalidated -= handler; + return; + } + + target.OnRoutingInvalidated(); + }; + + _routingChangedHandler = handler; + coreApp.RoutingInvalidated += handler; + } + + private void EnsureRootsNotificationHandler(McpServer server) + { + if (Interlocked.Exchange(ref _rootsNotificationRegistered, 1) != 0) + { + return; + } + + var weakSelf = new WeakReference(this); + _ = server.RegisterNotificationHandler( + NotificationMethods.RootsListChangedNotification, + (_, _) => + { + if (weakSelf.TryGetTarget(out var target)) + { + target._roots.HandleRootsListChanged(); + } + + return ValueTask.CompletedTask; + }); + } + + private void OnRoutingInvalidated() + { + _snapshotDirty = true; + + lock (_refreshLock) + { + _debounceTimer?.Dispose(); + _debounceTimer = _timeProvider.CreateTimer( + _ => _ = SendDiscoveryNotificationsSafeAsync(), + state: null, + dueTime: DebounceDelay, + period: Timeout.InfiniteTimeSpan); + } + } + + private async Task SendDiscoveryNotificationsSafeAsync() + { + await SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification).ConfigureAwait(false); + await SendNotificationSafeAsync(NotificationMethods.ResourceListChangedNotification).ConfigureAwait(false); + await SendNotificationSafeAsync(NotificationMethods.PromptListChangedNotification).ConfigureAwait(false); + } + + private async Task SendNotificationSafeAsync(string method) + { + try + { + var server = _server; + if (server is null) + { + return; + } + + await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Notifications are best-effort. The next list/read request will rebuild on demand. + } + } + + private void UnsubscribeFromRoutingChanges() + { + if (_routingChangedHandler is not null && _app is CoreReplApp coreApp) + { + coreApp.RoutingInvalidated -= _routingChangedHandler; + _routingChangedHandler = null; + } + + lock (_refreshLock) + { + _debounceTimer?.Dispose(); + _debounceTimer = null; + } + } + + private static ServerCapabilities BuildCapabilities() => new() + { + Tools = new ToolsCapability { ListChanged = true }, + Resources = new ResourcesCapability { ListChanged = true }, + Prompts = new PromptsCapability { ListChanged = true }, + }; + + private static Tool CreateCompatibilityDiscoverTool() => new() + { + Name = DiscoverToolsName, + Description = "Discover the current dynamic MCP tool list.", + InputSchema = JsonSerializer.SerializeToElement( + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject(), + ["additionalProperties"] = false, + }, + McpJsonContext.Default.JsonObject), + Annotations = new ToolAnnotations { ReadOnlyHint = true }, + }; + + private static Tool CreateCompatibilityCallTool() => new() + { + Name = CallToolName, + Description = "Call a dynamic MCP tool by name when the client cannot refresh the tool list.", + InputSchema = JsonSerializer.SerializeToElement( + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The real dynamic tool name to invoke.", + }, + ["arguments"] = new JsonObject + { + ["type"] = "object", + ["description"] = "The arguments to pass to the real dynamic tool.", + ["additionalProperties"] = true, + }, + }, + ["required"] = new JsonArray(CompatibilityCallRequiredProperties.Select(static property => JsonValue.Create(property)).ToArray()), + ["additionalProperties"] = false, + }, + McpJsonContext.Default.JsonObject), + }; + + private static CallToolResult BuildDiscoverToolsResult(McpGeneratedSnapshot snapshot) + { + var tools = snapshot.Tools.Select(static tool => tool.ProtocolTool).ToArray(); + var structuredContent = JsonSerializer.SerializeToElement(tools, McpJsonContext.Default.ToolArray); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Discovered {tools.Length} dynamic tool(s)." }], + StructuredContent = structuredContent, + IsError = false, }; } + private static async ValueTask InvokeCompatibilityToolAsync( + McpGeneratedSnapshot snapshot, + IDictionary arguments, + McpServer? server, + ProgressToken? progressToken, + CancellationToken cancellationToken) + { + if (!arguments.TryGetValue("name", out var nameElement) || nameElement.ValueKind != JsonValueKind.String) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = "Compatibility call_tool requires a string 'name' argument." }], + IsError = true, + }; + } + + var toolName = nameElement.GetString() ?? string.Empty; + var toolArguments = ExtractCompatibilityArguments(arguments); + return await snapshot.Adapter.InvokeAsync( + toolName, + toolArguments, + server, + progressToken, + cancellationToken).ConfigureAwait(false); + } + + private static IDictionary ExtractCompatibilityArguments( + IDictionary arguments) + { + if (!arguments.TryGetValue("arguments", out var nestedArguments) + || nestedArguments.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return EmptyArguments; + } + + if (nestedArguments.ValueKind != JsonValueKind.Object) + { + return new Dictionary(StringComparer.Ordinal) + { + ["arguments"] = nestedArguments.Clone(), + }; + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in nestedArguments.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + return result; + } + // ── Tool generation ──────────────────────────────────────────────── /// @@ -120,9 +617,6 @@ private List GenerateAllTools( var tools = new List(); var nameSet = new Dictionary(StringComparer.OrdinalIgnoreCase); - // 1. Regular commands. Resource-only and prompt-only commands are handled - // separately as fallback tools (opt-in). ReadOnly+AsResource commands - // are regular tools (they have a behavioral annotation, not just a marker). foreach (var command in model.Commands) { if (!IsToolCandidate(command)) @@ -132,18 +626,17 @@ private List GenerateAllTools( if (command.IsPrompt) { - continue; // Handled in phase 3 (prompt fallback). + continue; } if (command.IsResource && command.Annotations?.ReadOnly != true) { - continue; // Resource-only (no ReadOnly annotation) — handled in phase 2. + continue; } AddTool(command, tools, nameSet, adapter, separator); } - // 2. Resource fallback: resource-only commands as tools. if (_options.ResourceFallbackToTools) { foreach (var resource in model.Resources) @@ -156,7 +649,6 @@ private List GenerateAllTools( } } - // 3. Prompt fallback: prompt commands as tools. if (_options.PromptFallbackToTools) { foreach (var command in model.Commands) @@ -181,21 +673,17 @@ private static void AddTool( var toolName = McpToolNameFlattener.Flatten(command.Path, separator); if (nameSet.TryGetValue(toolName, out var existingPath)) { - // Same command registered from multiple phases (e.g. ReadOnly is both - // a core tool and a resource fallback) — skip silently. if (string.Equals(command.Path, existingPath, StringComparison.OrdinalIgnoreCase)) { return; } - // Different routes collapsed to the same name — surface at startup. throw new InvalidOperationException( $"MCP tool name collision: '{toolName}' from routes '{existingPath}' and '{command.Path}'. " + "Consider a different ToolNamingSeparator or rename one of the commands."); } nameSet[toolName] = command.Path; - adapter.RegisterRoute(toolName, command); tools.Add(new ReplMcpServerTool(command, toolName, adapter)); } @@ -214,13 +702,11 @@ private List GenerateResources( { commandsByPath.TryGetValue(resource.Path, out var docCommand); - // Hidden and AutomationHidden commands are excluded from all MCP surfaces. if (docCommand is not null && !IsToolCandidate(docCommand)) { continue; } - // Skip auto-promoted ReadOnly resources when opt-out is active. if (!_options.AutoPromoteReadOnlyToResources && docCommand is not null && !docCommand.IsResource @@ -228,6 +714,7 @@ private List GenerateResources( { continue; } + var resourceName = McpToolNameFlattener.Flatten(resource.Path, separator); var uriTemplate = McpToolNameFlattener.BuildResourceUri(resource.Path, _options.ResourceUriScheme); var mcpResource = new ReplMcpServerResource(resource, resourceName, uriTemplate, adapter); @@ -274,7 +761,6 @@ private List CollectPrompts( prompts[promptName] = new ReplMcpServerPrompt(command, promptName, adapter); } - // Explicit registrations via options.Prompt() — override on collision (by design). foreach (var registration in _options.Prompts) { prompts[registration.Name] = McpServerPrompt.Create( @@ -285,163 +771,14 @@ private List CollectPrompts( return [.. prompts.Values]; } - // ── Helpers ───────────────────────────────────────────────────────── - private bool IsToolCandidate(ReplDocCommand command) => !command.IsHidden && command.Annotations?.AutomationHidden != true && (_options.CommandFilter is not { } filter || filter(command)); - /// - /// Always advertise all capabilities, even when initial collections are empty. - /// Routing invalidation may add resources/prompts later, and capabilities cannot - /// be retroactively added after the initialize handshake. - /// - private static ServerCapabilities BuildCapabilities() => new() - { - Tools = new ToolsCapability { ListChanged = true }, - Resources = new ResourcesCapability { ListChanged = true }, - Prompts = new PromptsCapability { ListChanged = true }, - }; - - private static McpServerPrimitiveCollection ToCollection(IReadOnlyList items) - where T : IMcpServerPrimitive - { - var collection = new McpServerPrimitiveCollection(); - foreach (var item in items) - { - collection.Add(item); - } - - return collection; - } - - private static McpServerResourceCollection ToResourceCollection(IReadOnlyList items) - { - var collection = new McpServerResourceCollection(); - foreach (var item in items) - { - collection.Add(item); - } - - return collection; - } - - // ── list_changed on routing invalidation ─────────────────────────── - - private EventHandler? _routingChangedHandler; - private readonly Lock _refreshLock = new(); - private ITimer? _debounceTimer; - private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); - - private void SubscribeToRoutingChanges( - McpToolAdapter adapter, - char separator, - McpServerPrimitiveCollection toolCollection, - McpServerResourceCollection resourceCollection, - McpServerPrimitiveCollection promptCollection) - { - if (_app is not CoreReplApp coreApp) - { - return; - } - - _routingChangedHandler = (_, _) => - { - // Debounce: coalesce rapid-fire invalidations (e.g. multiple Map() calls) - // into a single rebuild after activity settles. - lock (_refreshLock) - { - _debounceTimer?.Dispose(); - _debounceTimer = _timeProvider.CreateTimer( - _ => RebuildCollections(adapter, separator, toolCollection, resourceCollection, promptCollection), - state: null, - dueTime: DebounceDelay, - period: Timeout.InfiniteTimeSpan); - } - }; - - coreApp.RoutingInvalidated += _routingChangedHandler; - } - - private void RebuildCollections( - McpToolAdapter adapter, - char separator, - McpServerPrimitiveCollection toolCollection, - McpServerResourceCollection resourceCollection, - McpServerPrimitiveCollection promptCollection) - { - try - { - // Optimistic concurrency: build new collections with a temporary adapter - // so existing routes remain live during rebuild. Only swap at the end. - var tempAdapter = new McpToolAdapter(_app, _options, _services); - var previousProgrammatic = ReplSessionIO.IsProgrammatic; - ReplSessionIO.IsProgrammatic = true; - List newTools; - List newResources; - List newPrompts; - try - { - var newModel = _app.CreateDocumentationModel(); - var newCommandsByPath = newModel.Commands.ToDictionary( - c => c.Path, c => c, StringComparer.OrdinalIgnoreCase); - newTools = GenerateAllTools(newModel, tempAdapter, separator, newCommandsByPath); - newResources = GenerateResources(newModel, tempAdapter, separator, newCommandsByPath); - newPrompts = CollectPrompts(newModel, tempAdapter, separator); - } - finally - { - ReplSessionIO.IsProgrammatic = previousProgrammatic; - } - - // Atomic swap: replace the adapter routes and collections in one lock. - lock (_refreshLock) - { - adapter.ReplaceRoutes(tempAdapter); - RefreshCollection(toolCollection, newTools); - RefreshCollection(resourceCollection, newResources); - RefreshCollection(promptCollection, newPrompts); - } - } - catch (Exception) - { - // Timer callbacks must not throw — an unhandled exception here would - // crash the process. The server continues with stale routes. - } - } - - private void UnsubscribeFromRoutingChanges() - { - if (_routingChangedHandler is not null && _app is CoreReplApp coreApp) - { - coreApp.RoutingInvalidated -= _routingChangedHandler; - _routingChangedHandler = null; - } - - lock (_refreshLock) - { - _debounceTimer?.Dispose(); - _debounceTimer = null; - } - } - - private static void RefreshCollection(McpServerPrimitiveCollection collection, IReadOnlyList items) - where T : IMcpServerPrimitive - { - collection.Clear(); - foreach (var item in items) - { - collection.Add(item); - } - } - - private static void RefreshCollection(McpServerResourceCollection collection, IReadOnlyList items) - { - collection.Clear(); - foreach (var item in items) - { - collection.Add(item); - } - } + internal sealed record McpGeneratedSnapshot( + McpToolAdapter Adapter, + List Tools, + List Resources, + List Prompts); } diff --git a/src/Repl.Mcp/McpServiceProviderOverlay.cs b/src/Repl.Mcp/McpServiceProviderOverlay.cs index 2d5bffe..1dcf0e9 100644 --- a/src/Repl.Mcp/McpServiceProviderOverlay.cs +++ b/src/Repl.Mcp/McpServiceProviderOverlay.cs @@ -1,19 +1,17 @@ -using Repl.Interaction; - namespace Repl.Mcp; /// -/// Service provider overlay that injects MCP-specific services (interaction channel). +/// Service provider overlay that injects MCP-specific services. /// internal sealed class McpServiceProviderOverlay( IServiceProvider inner, - IReplInteractionChannel interactionChannel) : IServiceProvider + IReadOnlyDictionary overrides) : IServiceProvider { public object? GetService(Type serviceType) { - if (serviceType == typeof(IReplInteractionChannel)) + if (overrides.TryGetValue(serviceType, out var service)) { - return interactionChannel; + return service; } return inner.GetService(serviceType); diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 5db856d..8b43754 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; +using Repl.Interaction; namespace Repl.Mcp; @@ -85,7 +86,12 @@ private async Task ExecuteThroughPipelineAsync( var inputReader = new StringReader(string.Empty); var interactionChannel = new McpInteractionChannel( prefills, _options.InteractivityMode, server, progressToken); - var mcpServices = new McpServiceProviderOverlay(_services, interactionChannel); + var mcpServices = new McpServiceProviderOverlay( + _services, + new Dictionary + { + [typeof(IReplInteractionChannel)] = interactionChannel, + }); // Force JSON output — agents consume structured data, not human tables/banners. var effectiveTokens = new List(tokens.Count + 1) { "--output:json" }; diff --git a/src/Repl.Mcp/ReplMcpServerOptions.cs b/src/Repl.Mcp/ReplMcpServerOptions.cs index 6b917f4..6276e48 100644 --- a/src/Repl.Mcp/ReplMcpServerOptions.cs +++ b/src/Repl.Mcp/ReplMcpServerOptions.cs @@ -79,6 +79,12 @@ public sealed class ReplMcpServerOptions /// public bool PromptFallbackToTools { get; set; } + /// + /// Controls the opt-in compatibility layer for clients that don't handle dynamic MCP tool lists well. + /// Leave this disabled for applications whose tool list is static. + /// + public DynamicToolCompatibilityMode DynamicToolCompatibility { get; set; } = DynamicToolCompatibilityMode.Disabled; + private readonly List _prompts = []; /// diff --git a/src/Repl.McpTests/Given_McpFallbackOptions.cs b/src/Repl.McpTests/Given_McpFallbackOptions.cs index 004c3d1..7f96d44 100644 --- a/src/Repl.McpTests/Given_McpFallbackOptions.cs +++ b/src/Repl.McpTests/Given_McpFallbackOptions.cs @@ -218,15 +218,11 @@ private static ( var app = ReplApp.Create(); configure(app); - var model = app.Core.CreateDocumentationModel(); var handler = new McpServerHandler(app.Core, options, EmptyServiceProvider.Instance); - var separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); - var adapter = new McpToolAdapter(app.Core, options, EmptyServiceProvider.Instance); - var serverOptions = handler.BuildServerOptions(model, adapter, separator); - - var tools = serverOptions.ToolCollection?.ToList() ?? []; - var resources = serverOptions.ResourceCollection?.ToList() ?? []; - var prompts = serverOptions.PromptCollection?.ToList() ?? []; + var snapshot = handler.BuildSnapshotForTests(); + var tools = snapshot.Tools; + var resources = snapshot.Resources; + var prompts = snapshot.Prompts; return (tools, resources, prompts); } diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs new file mode 100644 index 0000000..a9d39f0 --- /dev/null +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpRootsAndDynamicTools +{ + [TestMethod] + [Description("Native client roots are available to MCP command handlers when the client supports roots.")] + public async Task When_ClientSupportsRoots_Then_RootAwareToolCanReadThem() + { + 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", + }, + ], + }), + }, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.MapModule(new RootAwareModule()); + }, + configureOptions: null, + clientOptions); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().ContainSingle(t => string.Equals(t.Name, "roots_info", StringComparison.Ordinal)); + + var result = await fixture.Client.CallToolAsync( + "roots_info", + new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + var text = result.Content.OfType().First().Text; + text.Should().Contain("workspace"); + text.Should().Contain("file:///C:/workspace"); + } + + [TestMethod] + [Description("Soft roots can initialize MCP-only commands when native roots are unavailable.")] + public async Task When_ClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.MapModule( + new SoftRootsInitModule(), + (IMcpClientRoots roots) => !roots.IsSupported); + app.MapModule( + new SoftRootsWorkspaceModule(), + (IMcpClientRoots roots) => !roots.IsSupported && roots.HasSoftRoots); + }); + + var before = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + before.Should().Contain(t => string.Equals(t.Name, "softroots_init", StringComparison.Ordinal)); + before.Should().NotContain(t => string.Equals(t.Name, "softroots_show", StringComparison.Ordinal)); + + await fixture.Client.CallToolAsync( + "softroots_init", + new Dictionary(StringComparer.Ordinal) + { + ["path"] = "file:///C:/soft-workspace", + }).ConfigureAwait(false); + + var after = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + after.Should().Contain(t => string.Equals(t.Name, "softroots_show", StringComparison.Ordinal)); + + var show = await fixture.Client.CallToolAsync( + "softroots_show", + new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + var text = show.Content.OfType().First().Text; + text.Should().Contain("file:///C:/soft-workspace"); + } + + [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() + { + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map("echo {msg}", (string msg) => $"echo:{msg}"); + }, + options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + + await using var registration = fixture.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }); + + var firstTools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + firstTools.Select(static tool => tool.Name).Should().BeEquivalentTo( + [ "discover_tools", "call_tool" ]); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var discover = await fixture.Client.CallToolAsync( + "discover_tools", + new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + discover.IsError.Should().NotBeTrue(); + discover.StructuredContent.Should().NotBeNull(); + var discoveredTools = JsonSerializer.Deserialize( + discover.StructuredContent!.Value.GetRawText(), + McpJsonUtilities.DefaultOptions); + discoveredTools.Should().NotBeNull(); + discoveredTools!.Should().Contain(t => string.Equals(t.Name, "echo", StringComparison.Ordinal)); + + var compatibilityCall = await fixture.Client.CallToolAsync( + "call_tool", + new Dictionary(StringComparer.Ordinal) + { + ["name"] = "echo", + ["arguments"] = new Dictionary(StringComparer.Ordinal) + { + ["msg"] = "hello", + }, + }).ConfigureAwait(false); + compatibilityCall.IsError.Should().NotBeTrue(); + compatibilityCall.Content.OfType().First().Text.Should().Contain("echo:hello"); + + var secondTools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + secondTools.Should().Contain(t => string.Equals(t.Name, "echo", StringComparison.Ordinal)); + secondTools.Should().NotContain(t => string.Equals(t.Name, "discover_tools", StringComparison.Ordinal)); + } + + private sealed class RootAwareModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "roots info", + async (IMcpClientRoots roots, CancellationToken cancellationToken) => + { + var currentRoots = await roots.GetAsync(cancellationToken).ConfigureAwait(false); + return string.Join( + Environment.NewLine, + currentRoots.Select(static root => $"{root.Name}:{root.Uri}")); + }).ReadOnly(); + } + } + + private sealed class SoftRootsInitModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "softroots init {path}", + (IMcpClientRoots roots, string path) => + { + roots.SetSoftRoots([new McpClientRoot(new Uri(path, UriKind.Absolute), "soft-root")]); + return "initialized"; + }); + } + } + + private sealed class SoftRootsWorkspaceModule : IReplModule + { + public void Map(IReplMap app) + { + app.Map( + "softroots show", + (IMcpClientRoots roots) => roots.Current.Select(static root => root.Uri.ToString()).FirstOrDefault() ?? "none") + .ReadOnly(); + } + } +} diff --git a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs index edda751..2aaa5fb 100644 --- a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs +++ b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs @@ -9,8 +9,8 @@ namespace Repl.McpTests; public sealed class Given_McpServerOptionsBuilder { [TestMethod] - [Description("BuildMcpServerOptions returns options with tools from the app's command graph.")] - public void When_AppHasCommands_Then_OptionsContainTools() + [Description("BuildMcpServerOptions wires the dynamic tools handler from the app's command graph.")] + public void When_AppHasCommands_Then_OptionsContainListToolsHandler() { var app = ReplApp.Create(); app.Map("greet {name}", (string name) => $"Hello, {name}!").ReadOnly(); @@ -18,22 +18,21 @@ public void When_AppHasCommands_Then_OptionsContainTools() var options = app.Core.BuildMcpServerOptions(); - options.ToolCollection.Should().NotBeNull(); - options.ToolCollection!.Should().Contain(t => string.Equals(t.ProtocolTool.Name, "greet", StringComparison.Ordinal)); - options.ToolCollection!.Should().Contain(t => string.Equals(t.ProtocolTool.Name, "ping", StringComparison.Ordinal)); + options.Handlers.ListToolsHandler.Should().NotBeNull(); + options.Handlers.CallToolHandler.Should().NotBeNull(); } [TestMethod] - [Description("BuildMcpServerOptions returns options with resources from ReadOnly commands.")] - public void When_AppHasReadOnlyCommands_Then_OptionsContainResources() + [Description("BuildMcpServerOptions wires the dynamic resources handler from ReadOnly commands.")] + public void When_AppHasReadOnlyCommands_Then_OptionsContainResourcesHandler() { var app = ReplApp.Create(); app.Map("status", () => "ok").ReadOnly(); var options = app.Core.BuildMcpServerOptions(); - options.ResourceCollection.Should().NotBeNull(); - options.ResourceCollection!.Should().NotBeEmpty(); + options.Handlers.ListResourcesHandler.Should().NotBeNull(); + options.Handlers.ReadResourceHandler.Should().NotBeNull(); } [TestMethod] diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 64c6daa..0999318 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -35,11 +35,12 @@ private McpTestFixture( public McpClient Client { get; } public static Task CreateAsync(Action configure) => - CreateAsync(configure, configureOptions: null); + CreateAsync(configure, configureOptions: null, clientOptions: null); public static async Task CreateAsync( Action configure, - Action? configureOptions) + Action? configureOptions, + McpClientOptions? clientOptions = null) { var app = ReplApp.Create(); app.UseMcpServer(configureOptions); @@ -48,13 +49,8 @@ public static async Task CreateAsync( var options = new ReplMcpServerOptions(); configureOptions?.Invoke(options); - // Use the real McpServerHandler to build server options — exercises - // the full tool/resource/prompt generation pipeline with fallbacks. - var model = app.Core.CreateDocumentationModel(); - var adapter = new McpToolAdapter(app.Core, options, EmptyServiceProvider.Instance); - var separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); var handler = new McpServerHandler(app.Core, options, EmptyServiceProvider.Instance); - var serverOptions = handler.BuildServerOptions(model, adapter, separator); + var serverOptions = handler.BuildServerOptions(); var clientToServer = new Pipe(); var serverToClient = new Pipe(); @@ -74,7 +70,7 @@ public static async Task CreateAsync( var clientTransport = new StreamClientTransport( clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()); - var client = await McpClient.CreateAsync(clientTransport).ConfigureAwait(false); + var client = await McpClient.CreateAsync(clientTransport, clientOptions).ConfigureAwait(false); return new McpTestFixture(client, serverTask, cts, clientToServer, serverToClient); } From e9b75de191a40f53c558ed8078d995380ccf74d8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 09:46:18 -0400 Subject: [PATCH 2/8] Restructure MCP docs for guides and internals --- docs/mcp-advanced.md | 205 ++++++++++++++++++++++++++++------------- docs/mcp-internals.md | 134 +++++++++++++++++++++++++++ docs/mcp-server.md | 86 ++++++++++++++--- docs/mcp-transports.md | 73 +++++++++++++++ 4 files changed, 419 insertions(+), 79 deletions(-) create mode 100644 docs/mcp-internals.md create mode 100644 docs/mcp-transports.md diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index a061483..4760fe4 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -1,105 +1,180 @@ -# MCP Advanced: Custom Transports & HTTP Integration +# MCP Advanced: Dynamic Tools, Roots, and Session-Aware Patterns -This guide covers two advanced integration scenarios beyond the default stdio transport. +This guide covers advanced MCP usage patterns for Repl apps: -> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basics of exposing a Repl app as an MCP server. +- Tool visibility that changes per session +- Native MCP client roots +- Soft roots for clients that don't support roots +- Compatibility shims for clients that don't refresh dynamic tool lists well -## Scenario A: Stdio-over-anything +> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basic setup. +> +> **Need the plumbing details?** See [mcp-internals.md](mcp-internals.md). +> +> **Need custom transports or HTTP hosting?** See [mcp-transports.md](mcp-transports.md). -The MCP protocol is JSON-RPC over stdin/stdout. The `TransportFactory` option lets you replace the physical transport while keeping the same protocol — useful for WebSocket bridges, named pipes, SSH tunnels, etc. +## When this page matters -### How it works +Most Repl MCP servers don't need any of this. -`TransportFactory` receives the server name and an I/O context, and returns an `ITransport`. The MCP server uses this transport instead of `StdioServerTransport`. +Use the techniques in this page when: +- Available tools depend on login state, tenant, feature flags, or workspace +- The agent needs to know which directories it is allowed to work in +- Your MCP client does not support native roots +- Your MCP client does not seem to refresh its tool list after `list_changed` + +If your tool list is static, stay with the default setup from [mcp-server.md](mcp-server.md). + +## Client roots + +A **root** is a workspace or directory that the MCP client declares as being in scope for the session. + +Examples: +- The folder the user opened in the editor +- The project workspace attached to the agent +- A set of directories the agent is allowed to inspect + +When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`. ```csharp -app.UseMcpServer(o => -{ - o.TransportFactory = (serverName, io) => +app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) => { - // Bridge a WebSocket connection to MCP via streams. - var (inputStream, outputStream) = CreateWebSocketBridge(); - return new StreamServerTransport(inputStream, outputStream, serverName); - }; -}); + var current = await roots.GetAsync(ct); + return current.Select(r => new { r.Name, Uri = r.Uri.ToString() }); + }) + .WithDescription("List the current MCP workspace roots") + .ReadOnly(); ``` -The app still launches via `myapp mcp serve` — the framework handles the full MCP lifecycle (tool registration, routing invalidation, shutdown). This approach gives you **one session per process**. +Useful members: -### Multi-session (accept N connections) +| Member | Meaning | +|---|---| +| `IsSupported` | The connected client supports native MCP roots | +| `Current` | Current effective roots for the session | +| `GetAsync()` | Refreshes native roots if supported | +| `HasSoftRoots` | Fallback roots were initialized manually | +| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots for the current session | -For multiple concurrent sessions over a custom transport (e.g. a WebSocket listener accepting many clients), use `BuildMcpServerOptions` to build the options once, then create a server per connection: +## Session-aware routing + +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. ```csharp -var mcpOptions = app.Core.BuildMcpServerOptions(); +app.MapModule( + new WorkspaceModule(), + (IMcpClientRoots roots) => roots.IsSupported); +``` + +Typical session-aware conditions: +- Roots are available +- Soft roots were initialized +- The current tenant or login is known +- A module should appear only for one agent session + +## Soft roots fallback + +Some clients do not support MCP roots at all. In that case, a practical workaround is to expose an initialization tool only when roots are unavailable. + +The agent can call that tool first to establish one or more **soft roots** for the session. -// For each incoming WebSocket connection: -async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken ct) +```csharp +app.MapModule( + new SoftRootsInitModule(), + (IMcpClientRoots roots) => !roots.IsSupported); + +app.MapModule( + new WorkspaceModule(), + (IMcpClientRoots roots) => roots.IsSupported || roots.HasSoftRoots); + +sealed class SoftRootsInitModule : IReplModule { - var transport = new StreamServerTransport(input, output, "my-server"); - var server = McpServer.Create(transport, mcpOptions); - await server.RunAsync(ct); - await server.DisposeAsync(); + public void Map(IReplMap app) + { + app.Map("workspace init {path}", (IMcpClientRoots roots, string path) => + { + // SetSoftRoots invalidates routing for the current MCP session. + roots.SetSoftRoots([new McpClientRoot(new Uri(path, UriKind.Absolute), "workspace")]); + return "Workspace initialized."; + }) + // Message to agent asking it to set soft routes + .WithDescription("Before using other workspace tools, call this to set the working directory."); + } } ``` -Each session is fully isolated — tool invocations run in separate `AsyncLocal` scopes with their own I/O streams, just like hosted sessions. +Recommended instruction to give the agent: -### When to use +> If `workspace_init` is available, call it first with the working directory you should operate in. -- You have a non-stdio transport (WebSocket, named pipe, TCP) that carries the standard MCP JSON-RPC protocol -- Single-session: use `TransportFactory` via `mcp serve` (simplest) -- Multi-session: use `BuildMcpServerOptions` + one `McpServer.Create` per connection +This is often the simplest fallback for editor integrations or agent hosts that don't implement native roots. -## Scenario B: MCP-over-HTTP (Streamable HTTP) +## Dynamic tool compatibility shim -The MCP spec defines a native HTTP transport: POST for client→server messages, GET/SSE for server→client streaming, with session management. This requires an HTTP host (typically ASP.NET Core) rather than a CLI command. +Some MCP clients receive `notifications/tools/list_changed` but do not refresh their tool list correctly. -### How it works - -`BuildMcpServerOptions()` constructs the full `McpServerOptions` (tools, resources, prompts, capabilities) from your Repl app's command graph — without starting a server. You pass these options to the MCP C# SDK's HTTP integration. +If your app has a dynamic tool list, you can opt in to a compatibility shim: ```csharp -var app = ReplApp.Create(); -app.Map("greet {name}", (string name) => $"Hello, {name}!"); -app.Map("status", () => "all systems go").ReadOnly(); - -// Build MCP options from the command graph. -var mcpOptions = app.Core.BuildMcpServerOptions(configure: o => +app.UseMcpServer(o => { - o.ServerName = "MyApi"; - o.ResourceUriScheme = "myapi"; + o.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim; }); +``` -// Use with McpServer.Create for a custom HTTP handler... -var server = McpServer.Create(httpTransport, mcpOptions); +When enabled: -// ...or pass the collections to ASP.NET Core's MapMcp. -``` +1. The first `tools/list` returns only `discover_tools` and `call_tool` +2. The server emits `notifications/tools/list_changed` +3. Later `tools/list` calls return the real tool set + +This lets limited clients continue operating: +- `discover_tools` returns the current real tools and schemas +- `call_tool` invokes a real tool by name and arguments + +Use this only when you need it. + +Good candidates: +- Tools appear after authentication +- Tools depend on roots or soft roots +- Tools vary by session or runtime context + +Avoid it when: +- Your tool list is static +- Your client already handles `list_changed` correctly + +## Choosing the right fallback -### Multi-session +| Problem | Recommended approach | +|---|---| +| Client supports roots and refreshes tools correctly | Use the default MCP setup | +| Client does not support roots | Add a soft-roots initialization tool | +| Client supports tools but misses dynamic refreshes | Enable `DiscoverAndCallShim` | +| Client has both issues | Use soft roots and, if needed, the dynamic tool shim | -Each HTTP request creates an isolated MCP session. This uses the same mechanism as Repl's hosted sessions: +## Troubleshooting patterns -- `ReplSessionIO.SetSession()` creates an `AsyncLocal` scope per request -- Each session has its own output writer, input reader, and session ID -- Tool invocations are fully isolated — concurrent requests don't interfere +### The agent doesn't see tools that should appear later -This is identical to how the framework handles concurrent tool calls in stdio mode (via `McpToolAdapter.ExecuteThroughPipelineAsync`). +Check: +- Your app calls `InvalidateRouting()` when session-driven state changes +- The client actually refreshes after `list_changed` +- `DynamicToolCompatibility` is enabled if the client is weak on dynamic discovery -### When to use +If needed, see [mcp-server.md](mcp-server.md#troubleshooting) for the quick checklist and [mcp-internals.md](mcp-internals.md) for the behavior details. -- You're building a web API that also exposes MCP endpoints -- You need multiple concurrent MCP sessions (agents connecting via HTTP) -- You want to integrate with the ASP.NET Core pipeline (auth, middleware, etc.) +### The agent doesn't know which workspace to use -## Configuration reference +Check: +- Whether the client supports native roots +- Whether a roots-aware tool can inspect `IMcpClientRoots` +- Whether you need a soft-roots init tool -| Option | Default | Description | -|--------|---------|-------------| -| `TransportFactory` | `null` (stdio) | Custom transport factory for Scenario A | -| `ResourceUriScheme` | `"repl"` | URI scheme for MCP resources (`{scheme}://path`) | -| `ServerName` | Assembly product name | Server name in MCP `initialize` response | -| `ServerVersion` | `"1.0.0"` | Server version in MCP `initialize` response | +### My module predicate depends on roots but never activates -See [mcp-server.md](mcp-server.md) for the full configuration reference. +Check: +- Whether the client actually advertises roots support +- Whether you need `await roots.GetAsync(...)` in a handler rather than only a predicate +- Whether soft roots are a better fit for that client diff --git a/docs/mcp-internals.md b/docs/mcp-internals.md new file mode 100644 index 0000000..1327a16 --- /dev/null +++ b/docs/mcp-internals.md @@ -0,0 +1,134 @@ +# MCP Internals: Concepts and Under-the-Hood Behavior + +This guide explains how the Repl MCP integration works internally and what problems the advanced features are solving. + +> If you just want recipes and snippets, use [mcp-server.md](mcp-server.md) and [mcp-advanced.md](mcp-advanced.md). + +## Roots + +In MCP, a **root** is a URI the client declares as being in scope for the session. + +In practice, a root often represents: +- An opened project folder +- A working directory +- A boundary for what the agent should inspect or modify + +Roots are useful because they give the server session-specific workspace context without inventing its own protocol. + +## Native roots vs soft roots + +There are two ways a Repl MCP session can get roots: + +| Kind | Source | When to use | +|---|---|---| +| Native roots | The MCP client implements the roots capability | Preferred | +| Soft roots | Your app asks the agent to initialize workspace paths manually | Fallback | + +`IMcpClientRoots` abstracts over both. + +If native roots are available, `GetAsync()` requests them from the client. +If not, your app can establish soft roots with `SetSoftRoots()`. + +## 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 `IMcpClientRoots` lives in `Repl.Mcp` and is injected only for MCP execution. + +## Session-aware routing + +Repl supports dynamic module presence. For MCP, this matters because available tools may depend on: +- Client capabilities +- Session state +- Roots +- Authentication +- Tenant or workspace selection + +To support that, the MCP integration builds its documentation model and MCP surfaces using the current MCP session service provider, not just the app root service provider. + +That is what makes session-scoped services like `IMcpClientRoots` visible to: +- Module presence predicates +- Tool handlers +- Prompt handlers +- Resource handlers + +## Why tools can be dynamic + +In a normal CLI app, the command graph is usually static. + +In MCP, the effective tool set may change during a session. Examples: +- A login tool disappears after authentication and admin tools appear +- Tools appear only after a workspace is known +- Session-scoped modules become active after an initialization call + +When this happens, the app calls `InvalidateRouting()`. +The MCP layer then rebuilds the active graph and emits: + +- `notifications/tools/list_changed` +- `notifications/resources/list_changed` +- `notifications/prompts/list_changed` + +## Why the compatibility shim exists + +The MCP protocol already has a way to refresh discovery: `list_changed`. + +The problem is client support. Some agents: +- don't implement `list_changed` +- implement it partially +- receive the notification but keep using a stale tool list + +That is what `DynamicToolCompatibilityMode.DiscoverAndCallShim` is solving. + +Instead of assuming the client will refresh properly, the server can: + +1. Expose `discover_tools` +2. Expose `call_tool` +3. Notify that the list changed +4. Fall back to the real tools on later `tools/list` + +So even a weak client can still: +- discover the real tools manually +- call those tools manually + +This mode is intentionally opt-in because it adds extra behavior only needed by dynamic-tool apps targeting imperfect clients. + +## Why soft roots are useful + +Native roots are the cleanest solution, but not all clients implement them. + +Without roots, the server still needs some way to learn: +- which project the agent is working on +- which directories are allowed +- which workspace-specific tools should appear + +Soft roots are a simple convention: +- expose an init tool only when roots are unavailable +- tell the agent to call it first +- store those roots in the MCP session +- invalidate routing so the session-aware tool graph can update + +This pattern is not part of the MCP specification. +It is an application-level fallback for clients with incomplete capability support. + +## Why `discover_tools` and `call_tool` are reserved names + +When the compatibility shim is enabled, those two names become part of the protocol surface exposed by the app. + +To avoid ambiguity, Repl reserves: +- `discover_tools` +- `call_tool` + +If a real command would flatten to one of those names while the shim is enabled, startup fails with an explicit collision error. + +## Transport separation + +Custom transports and HTTP hosting are advanced too, but they are a separate concern. + +They answer: +- How does MCP traffic reach the server? + +The topics in this page answer: +- How does the tool graph adapt per session? +- How does the server reason about roots and client capability gaps? + +For transport and hosting integration, see [mcp-transports.md](mcp-transports.md). diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 5871d44..1820fe7 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -4,6 +4,11 @@ Expose your Repl command graph as an [MCP](https://modelcontextprotocol.io) (Mod See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working demo. +Related guides: +- [mcp-advanced.md](mcp-advanced.md) for roots, soft roots, and dynamic tool patterns +- [mcp-transports.md](mcp-transports.md) for custom transports and HTTP hosting +- [mcp-internals.md](mcp-internals.md) for concepts and under-the-hood behavior + ## Quick start ```bash @@ -28,9 +33,11 @@ One command graph. CLI, REPL, remote sessions, and AI agents — all from the sa > **Note:** `UseMcpServer()` registers a hidden `mcp serve` context. The tool list is built lazily when an agent connects, so it sees all commands regardless of registration order. -## How it works +## What it does + +`UseMcpServer()` registers a hidden `mcp serve` command that starts an MCP stdio server. The connected agent then sees your Repl command graph as MCP tools, resources, and prompts. -`UseMcpServer()` registers a hidden `mcp serve` command that starts an MCP stdio server. When an agent connects, the server reads the command graph via `CreateDocumentationModel()`, generates typed JSON Schema for each command, and dispatches tool calls through the standard Repl pipeline (routing, binding, middleware, rendering). +If you want the internal mechanics, see [mcp-internals.md](mcp-internals.md). Commands map to MCP primitives: @@ -240,18 +247,24 @@ app.Map("import", async (IReplInteractionChannel interaction, CancellationToken ## Writing output in MCP mode -In MCP mode, each tool call runs in an isolated session with a captured output stream. Understanding where output goes is important for writing commands that work well with agents. +In MCP mode, each tool call runs in an isolated session. Repl commands should communicate through return values and `IReplInteractionChannel`, not through `Console.*`. + +This is not just a style preference: +- In normal Repl code, `Console.*` bypasses the framework's output model +- In MCP mode, some console writes can corrupt the protocol stream +- Even when captured, ad-hoc console output makes tool results ambiguous and harder to consume correctly | Output method | Where it goes | Recommendation | |---|---|---| | **Return value** | Serialized to JSON → `CallToolResult.Content` | **Preferred.** Clean, structured, always correct. | | **`IReplInteractionChannel`** | Intercepted by `McpInteractionChannel` | **Use for prompts and progress.** Maps to MCP primitives. | -| **`ReplSessionIO.Output`** / `Console.WriteLine` | Captured in `StringWriter` → appended to tool result as text | Works, but produces raw text mixed with the serialized return value. Use intentionally. | +| **`ReplSessionIO.Output`** | Writes to the session output | Reserve for advanced cases. Prefer return values for data and `IReplInteractionChannel` for runtime interaction. | +| **`Console.WriteLine`** | Bypasses the Repl abstraction; may be captured indirectly in some cases | **Avoid.** This is an anti-pattern in Repl code, and especially wrong in MCP handlers. | | **`Console.OpenStandardOutput()`** | **Writes directly to the MCP stdio transport** | **Never use.** Corrupts the JSON-RPC protocol stream. | -The key rule: **use return values and `IReplInteractionChannel`**. These are the designed integration points that produce clean, structured results for agents. Direct console writes are captured and won't crash anything, but they produce unstructured text that agents may struggle to parse. +The key rule: **use return values and `IReplInteractionChannel`**. These are the designed integration points in Repl and the only ones you should rely on for MCP-facing commands. -> **Token cost:** Everything returned in `CallToolResult.Content` is consumed by the LLM as input tokens. Verbose output (debug logs, large tables, raw dumps) translates directly into token cost and can degrade agent reasoning. Keep tool output concise and structured — return what the agent needs to act, not everything you'd show a human. +> **Why this matters:** extra text in tool output increases token usage, but the more serious issue is correctness. Console-style writes blur the boundary between structured result data, progress, logs, and protocol traffic. In MCP code, that can range from confusing agent behavior to outright protocol corruption. ```csharp // Good: structured return value @@ -264,11 +277,18 @@ app.Map("import", async (IReplInteractionChannel ch, CancellationToken ct) => return Results.Success("Done."); }); -// Avoid: raw console output mixed with return value +// If you really need textual session output, use Repl's IO abstraction +app.Map("status", async () => +{ + await ReplSessionIO.Output.WriteLineAsync("Loading..."); + return new { Status = "ok" }; +}); + +// Don't: bypass Repl's IO model with Console.* app.Map("status", () => { - Console.WriteLine("Loading..."); // captured but messy - return new { Status = "ok" }; // agent sees "Loading...\n{...}" + Console.WriteLine("Loading..."); + return new { Status = "ok" }; }); ``` @@ -299,15 +319,40 @@ app.UseMcpServer(o => }); ``` +## Troubleshooting + +### New tools are not visible to the agent + +Check: +- Whether your app calls `InvalidateRouting()` when the effective command graph changes +- Whether the MCP client actually refreshes after `notifications/tools/list_changed` +- Whether your tool list is truly dynamic per session + +If your client seems to ignore dynamic tool refreshes, see [mcp-advanced.md](mcp-advanced.md) and enable the opt-in compatibility shim. + +### The agent does not see workspace roots + +Some MCP clients do not support native roots. + +If you need workspace-aware behavior: +- Use `IMcpClientRoots` when native roots are supported +- Add a soft-roots initialization tool when they are not + +See [mcp-advanced.md](mcp-advanced.md) for the full pattern. + +### I need to understand why this behaves that way + +See [mcp-internals.md](mcp-internals.md) for: +- what roots are +- how session-aware routing works +- why `list_changed` is not always enough +- how the compatibility shim works + ## Known limitations - **Collection parameters** (`List`, `int[]`): MCP passes JSON arrays as a single element. The CLI binding layer expects repeated values (`--tag vip --tag priority`), so collection parameters are not correctly bound from MCP tool calls yet. Use string parameters with custom parsing as a workaround. - **Parameterized resources**: Commands with route parameters (e.g. `config {env}`) marked `.AsResource()` are exposed as MCP resource templates with URI variables (e.g. `repl://config/{env}`). Agents read them via `resources/read` with the concrete URI (e.g. `repl://config/production`) and the parameters are passed to the command handler. -## Advanced: custom transports & HTTP - -For custom transports (WebSocket, named pipes, SSH) or HTTP integration (ASP.NET Core Streamable HTTP), see [mcp-advanced.md](mcp-advanced.md). - ## Configuration options ```csharp @@ -321,6 +366,7 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenFail; // interaction degradation o.ResourceFallbackToTools = false; // opt-in: also expose resources as tools o.PromptFallbackToTools = false; // opt-in: also expose prompts as tools + o.DynamicToolCompatibility = DynamicToolCompatibilityMode.Disabled; // opt-in shim for clients that miss dynamic tool refresh o.CommandFilter = cmd => true; // filter which commands become tools o.Prompt("summarize", (string topic) => ...); // explicit prompt registration }); @@ -444,7 +490,16 @@ Include this file in the package to enable registry-based discovery: "identifier": "MyApp", "version": "1.0.0", "transport": { "type": "stdio" }, - "packageArguments": ["mcp", "serve"], + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + }, + { + "type": "positional", + "value": "serve" + } + ], "environmentVariables": [] } ] @@ -461,6 +516,9 @@ dotnet nuget push bin/Release/MyApp.1.0.0.nupkg --source https://api.nuget.org/v # Install and run dotnet tool install -g MyApp myapp mcp serve + +# Run without install +dnx -y MyApp -- mcp serve ``` NuGet.org discovery: `nuget.org/packages?packagetype=mcpserver` diff --git a/docs/mcp-transports.md b/docs/mcp-transports.md new file mode 100644 index 0000000..1ea046a --- /dev/null +++ b/docs/mcp-transports.md @@ -0,0 +1,73 @@ +# MCP Transports: Custom Transports and HTTP Integration + +This guide covers advanced transport and hosting scenarios beyond the default stdio transport. + +> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basic setup. + +## Scenario A: Stdio-over-anything + +The MCP protocol is JSON-RPC over stdin/stdout. The `TransportFactory` option lets you replace the physical transport while keeping the same protocol. + +Use this for: +- WebSocket bridges +- Named pipes +- SSH tunnels +- Any other stream-based transport + +```csharp +app.UseMcpServer(o => +{ + o.TransportFactory = (serverName, io) => + { + var (inputStream, outputStream) = CreateWebSocketBridge(); + return new StreamServerTransport(inputStream, outputStream, serverName); + }; +}); +``` + +The app still launches via `myapp mcp serve`. This gives you one MCP session per process. + +### Multi-session custom transports + +If your transport accepts multiple concurrent connections, build `McpServerOptions` once and create a server per connection: + +```csharp +var mcpOptions = app.Core.BuildMcpServerOptions(); + +async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken ct) +{ + var transport = new StreamServerTransport(input, output, "my-server"); + var server = McpServer.Create(transport, mcpOptions); + await server.RunAsync(ct); + await server.DisposeAsync(); +} +``` + +## 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`. + +```csharp +var app = ReplApp.Create(); +app.Map("greet {name}", (string name) => $"Hello, {name}!"); +app.Map("status", () => "all systems go").ReadOnly(); + +var mcpOptions = app.Core.BuildMcpServerOptions(configure: o => +{ + o.ServerName = "MyApi"; + o.ResourceUriScheme = "myapi"; +}); +``` + +You can then pass those options to the MCP SDK's HTTP integration. + +## Session isolation + +Each connection or HTTP session is isolated: +- its own MCP session +- its own I/O capture +- its own session-aware routing state + +That matters especially when using dynamic tools, roots, or session-specific modules. + +For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md). From 63d0c898ef35c9223d2bd1edfffdfaac578d60b6 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 09:55:23 -0400 Subject: [PATCH 3/8] Add workspace resolution guidance to MCP docs --- docs/mcp-advanced.md | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index 4760fe4..400fd95 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -74,6 +74,80 @@ Typical session-aware conditions: - The current tenant or login is known - A module should appear only for one agent session +## Guidance: MCP-only vs workspace-aware commands + +`IMcpClientRoots` is MCP-scoped, but that does not automatically mean every command using it must be MCP-only. + +There are two useful patterns: + +### Pattern 1: MCP-only commands + +Use this when the command only makes sense inside an MCP session. + +```csharp +app.MapModule( + new WorkspaceBootstrapModule(), + (IMcpClientRoots? roots) => roots is not null); +``` + +This is the simplest option when: +- the command exists only to help an agent initialize MCP session state +- the command depends directly on MCP capabilities +- showing it in CLI or interactive Repl would be confusing + +### Pattern 2: Workspace-aware commands + +Use this when the command should work both inside and outside MCP. + +In that case, treat MCP roots as just one possible source of workspace context, not the only source. + +Typical workspace sources: + +1. native MCP roots +2. MCP soft roots +3. session state in Repl +4. a command-line argument or explicit option +5. the process current directory + +For example: + +```csharp +app.Map("workspace status", async (IMcpClientRoots? roots, IReplSessionState state, CancellationToken ct) => + { + var workspace = + roots is not null + ? (await roots.GetAsync(ct)).FirstOrDefault()?.Uri?.ToString() + : state.Get("workspace.path"); + + return workspace is null + ? "No workspace selected." + : $"Workspace: {workspace}"; + }) + .ReadOnly(); +``` + +And you can pair that with a general-purpose Repl command: + +```csharp +app.Map("workspace set {path}", (IReplSessionState state, string path) => + { + state.Set("workspace.path", path); + return "Workspace updated."; + }); +``` + +This pattern is often better than making everything MCP-only. + +### Recommendation + +When a command needs a working directory or workspace, design it around a **workspace resolution strategy** instead of assuming one single source. + +That usually makes the command: +- more reusable +- easier to test +- usable from CLI, hosted sessions, and MCP +- easier to adapt when some clients support roots and others do not + ## Soft roots fallback Some clients do not support MCP roots at all. In that case, a practical workaround is to expose an initialization tool only when roots are unavailable. From 2d8bff1efb4a68d2624058c09d31f6ba71c028ce Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 10:01:29 -0400 Subject: [PATCH 4/8] Tighten MCP exception handling for snapshot refresh --- src/Repl.Mcp/McpServerHandler.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index a76812e..8aa5010 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -299,7 +299,11 @@ private async ValueTask GetSnapshotAsync( _snapshotDirty = false; return built; } - catch when (previousSnapshot is not null) + catch (OperationCanceledException) + { + throw; + } + catch (Exception) when (previousSnapshot is not null) { _snapshot = previousSnapshot; _snapshotDirty = false; @@ -463,7 +467,11 @@ private async Task SendNotificationSafeAsync(string method) await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); } - catch + 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. } From 596ff2c40f0c71063c45ecc65cbf9b0dab46c47a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 10:11:25 -0400 Subject: [PATCH 5/8] Align public MCP APIs under Repl.Mcp namespace --- README.md | 2 ++ docs/mcp-advanced.md | 12 +++++++++ docs/mcp-server.md | 2 ++ src/Repl.Core/CoreReplApp.Documentation.cs | 2 +- .../Internal/Options/OptionSchemaBuilder.cs | 2 +- src/Repl.Mcp/DynamicToolCompatibilityMode.cs | 2 +- src/Repl.Mcp/IMcpClientRoots.cs | 2 +- src/Repl.Mcp/InteractivityMode.cs | 2 +- src/Repl.Mcp/McpClientRoot.cs | 2 +- src/Repl.Mcp/McpPromptRegistration.cs | 2 +- src/Repl.Mcp/McpReplExtensions.cs | 3 +-- src/Repl.Mcp/README.md | 2 ++ src/Repl.Mcp/ReplMcpServerOptions.cs | 2 +- src/Repl.Mcp/ToolNamingSeparator.cs | 2 +- src/Repl.McpTests/Given_McpIntegration.cs | 2 ++ .../Given_McpRootsAndDynamicTools.cs | 27 ++++++++++--------- .../Given_McpServerOptionsBuilder.cs | 1 + .../Given_McpTransportFactory.cs | 1 + 18 files changed, 46 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index feca84a..a0017ec 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Globex **MCP mode** (same command graph, exposed to AI agents): ```csharp +using Repl.Mcp; + app.UseMcpServer(); // add one line ``` diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index 400fd95..063c601 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -37,6 +37,8 @@ Examples: When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`. ```csharp +using Repl.Mcp; + app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) => { var current = await roots.GetAsync(ct); @@ -63,6 +65,8 @@ Because `IMcpClientRoots` is injectable, you can use it in command handlers and That lets you expose tools only when a certain MCP capability or session state is available. ```csharp +using Repl.Mcp; + app.MapModule( new WorkspaceModule(), (IMcpClientRoots roots) => roots.IsSupported); @@ -85,6 +89,8 @@ There are two useful patterns: Use this when the command only makes sense inside an MCP session. ```csharp +using Repl.Mcp; + app.MapModule( new WorkspaceBootstrapModule(), (IMcpClientRoots? roots) => roots is not null); @@ -112,6 +118,8 @@ Typical workspace sources: For example: ```csharp +using Repl.Mcp; + app.Map("workspace status", async (IMcpClientRoots? roots, IReplSessionState state, CancellationToken ct) => { var workspace = @@ -155,6 +163,8 @@ Some clients do not support MCP roots at all. In that case, a practical workarou The agent can call that tool first to establish one or more **soft roots** for the session. ```csharp +using Repl.Mcp; + app.MapModule( new SoftRootsInitModule(), (IMcpClientRoots roots) => !roots.IsSupported); @@ -192,6 +202,8 @@ Some MCP clients receive `notifications/tools/list_changed` but do not refresh t If your app has a dynamic tool list, you can opt in to a compatibility shim: ```csharp +using Repl.Mcp; + app.UseMcpServer(o => { o.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim; diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 1820fe7..9e7b122 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -16,6 +16,8 @@ dotnet add package Repl.Mcp ``` ```csharp +using Repl.Mcp; + var app = ReplApp.Create().UseDefaultInteractive(); app.UseMcpServer(); diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index 56dc267..d7dc4da 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -296,7 +296,7 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(IReplInteractionChannel) || parameterType == typeof(IReplIoContext) || parameterType == typeof(IReplKeyReader) - || string.Equals(parameterType.FullName, "Repl.IMcpClientRoots", StringComparison.Ordinal); + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); private static bool IsRequiredParameter(ParameterInfo parameter) { diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index fd713e7..26f47ed 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -198,7 +198,7 @@ private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) => || parameter.ParameterType == typeof(IReplInteractionChannel) || parameter.ParameterType == typeof(IReplIoContext) || parameter.ParameterType == typeof(IReplKeyReader) - || string.Equals(parameter.ParameterType.FullName, "Repl.IMcpClientRoots", StringComparison.Ordinal); + || string.Equals(parameter.ParameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) { diff --git a/src/Repl.Mcp/DynamicToolCompatibilityMode.cs b/src/Repl.Mcp/DynamicToolCompatibilityMode.cs index a1182fc..1a39847 100644 --- a/src/Repl.Mcp/DynamicToolCompatibilityMode.cs +++ b/src/Repl.Mcp/DynamicToolCompatibilityMode.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Controls the compatibility strategy used for dynamic MCP tool lists. diff --git a/src/Repl.Mcp/IMcpClientRoots.cs b/src/Repl.Mcp/IMcpClientRoots.cs index fca7167..dcfc1a3 100644 --- a/src/Repl.Mcp/IMcpClientRoots.cs +++ b/src/Repl.Mcp/IMcpClientRoots.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Provides access to MCP client roots for the current MCP session. diff --git a/src/Repl.Mcp/InteractivityMode.cs b/src/Repl.Mcp/InteractivityMode.cs index daa2c4f..1444456 100644 --- a/src/Repl.Mcp/InteractivityMode.cs +++ b/src/Repl.Mcp/InteractivityMode.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Controls how runtime interaction prompts are handled in MCP mode. diff --git a/src/Repl.Mcp/McpClientRoot.cs b/src/Repl.Mcp/McpClientRoot.cs index 278906d..213e5de 100644 --- a/src/Repl.Mcp/McpClientRoot.cs +++ b/src/Repl.Mcp/McpClientRoot.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Describes an MCP client root. diff --git a/src/Repl.Mcp/McpPromptRegistration.cs b/src/Repl.Mcp/McpPromptRegistration.cs index 90836b4..22f0c3c 100644 --- a/src/Repl.Mcp/McpPromptRegistration.cs +++ b/src/Repl.Mcp/McpPromptRegistration.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Registration entry for an explicit MCP prompt. diff --git a/src/Repl.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index b1e2ffb..ac11ca1 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -1,8 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Server; -using Repl.Mcp; -namespace Repl; +namespace Repl.Mcp; /// /// Extension methods for integrating MCP server support into a Repl app. diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 8ca084e..901411b 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -5,6 +5,8 @@ MCP server integration for [Repl Toolkit](https://github.com/yllibed/repl) — e ## One line to add ```csharp +using Repl.Mcp; + app.UseMcpServer(); ``` diff --git a/src/Repl.Mcp/ReplMcpServerOptions.cs b/src/Repl.Mcp/ReplMcpServerOptions.cs index 6276e48..e45dcaa 100644 --- a/src/Repl.Mcp/ReplMcpServerOptions.cs +++ b/src/Repl.Mcp/ReplMcpServerOptions.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using Repl.Documentation; -namespace Repl; +namespace Repl.Mcp; /// /// Configuration for the Repl MCP server integration. diff --git a/src/Repl.Mcp/ToolNamingSeparator.cs b/src/Repl.Mcp/ToolNamingSeparator.cs index 9352ba8..cc11383 100644 --- a/src/Repl.Mcp/ToolNamingSeparator.cs +++ b/src/Repl.Mcp/ToolNamingSeparator.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Mcp; /// /// Separator style for flattening context paths into MCP tool names. diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 411c0bb..75a2cca 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,3 +1,5 @@ +using Repl.Mcp; + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index a9d39f0..5f8d08b 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -2,6 +2,7 @@ using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using Repl.Mcp; namespace Repl.McpTests; @@ -40,14 +41,14 @@ public async Task When_ClientSupportsRoots_Then_RootAwareToolCanReadThem() app.MapModule(new RootAwareModule()); }, configureOptions: null, - clientOptions); + clientOptions: clientOptions); var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); tools.Should().ContainSingle(t => string.Equals(t.Name, "roots_info", StringComparison.Ordinal)); var result = await fixture.Client.CallToolAsync( - "roots_info", - new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + toolName: "roots_info", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); var text = result.Content.OfType().First().Text; text.Should().Contain("workspace"); text.Should().Contain("file:///C:/workspace"); @@ -57,7 +58,7 @@ public async Task When_ClientSupportsRoots_Then_RootAwareToolCanReadThem() [Description("Soft roots can initialize MCP-only commands when native roots are unavailable.")] public async Task When_ClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace() { - await using var fixture = await McpTestFixture.CreateAsync(app => + await using var fixture = await McpTestFixture.CreateAsync(configure: app => { app.MapModule( new SoftRootsInitModule(), @@ -73,7 +74,7 @@ public async Task When_ClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWork await fixture.Client.CallToolAsync( "softroots_init", - new Dictionary(StringComparer.Ordinal) + arguments: new Dictionary(StringComparer.Ordinal) { ["path"] = "file:///C:/soft-workspace", }).ConfigureAwait(false); @@ -82,8 +83,8 @@ await fixture.Client.CallToolAsync( after.Should().Contain(t => string.Equals(t.Name, "softroots_show", StringComparison.Ordinal)); var show = await fixture.Client.CallToolAsync( - "softroots_show", - new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + toolName: "softroots_show", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); var text = show.Content.OfType().First().Text; text.Should().Contain("file:///C:/soft-workspace"); } @@ -99,7 +100,7 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd { app.Map("echo {msg}", (string msg) => $"echo:{msg}"); }, - options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); await using var registration = fixture.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, @@ -116,8 +117,8 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); var discover = await fixture.Client.CallToolAsync( - "discover_tools", - new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + toolName: "discover_tools", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); discover.IsError.Should().NotBeTrue(); discover.StructuredContent.Should().NotBeNull(); var discoveredTools = JsonSerializer.Deserialize( @@ -127,8 +128,8 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd discoveredTools!.Should().Contain(t => string.Equals(t.Name, "echo", StringComparison.Ordinal)); var compatibilityCall = await fixture.Client.CallToolAsync( - "call_tool", - new Dictionary(StringComparer.Ordinal) + toolName: "call_tool", + arguments: new Dictionary(StringComparer.Ordinal) { ["name"] = "echo", ["arguments"] = new Dictionary(StringComparer.Ordinal) @@ -168,7 +169,7 @@ public void Map(IReplMap app) "softroots init {path}", (IMcpClientRoots roots, string path) => { - roots.SetSoftRoots([new McpClientRoot(new Uri(path, UriKind.Absolute), "soft-root")]); + roots.SetSoftRoots([new McpClientRoot(Uri: new Uri(path, UriKind.Absolute), Name: "soft-root")]); return "initialized"; }); } diff --git a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs index 2aaa5fb..329f68f 100644 --- a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs +++ b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Repl.Mcp; namespace Repl.McpTests; diff --git a/src/Repl.McpTests/Given_McpTransportFactory.cs b/src/Repl.McpTests/Given_McpTransportFactory.cs index db2d009..ae34694 100644 --- a/src/Repl.McpTests/Given_McpTransportFactory.cs +++ b/src/Repl.McpTests/Given_McpTransportFactory.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Server; +using Repl.Mcp; using static Repl.McpTests.McpTestFixture; namespace Repl.McpTests; From 5f6dc70bfe9d1d73f0f240f842f11846e72297e0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 10:25:12 -0400 Subject: [PATCH 6/8] Fix MCP snapshot invalidation and sample namespace --- samples/08-mcp-server/Program.cs | 1 + src/Repl.Mcp/McpClientRootsService.cs | 38 +++++++++++++++++++------- src/Repl.Mcp/McpServerHandler.cs | 39 +++++++++++++++++---------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 0fc6f72..b64b134 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Repl; +using Repl.Mcp; // ── A Repl app exposed as an MCP server for AI agents ────────────── // diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index 2522d64..fa53d4e 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -11,6 +11,7 @@ internal sealed class McpClientRootsService : IMcpClientRoots private McpClientRoot[] _hardRoots = []; private McpClientRoot[] _softRoots = []; private bool _hardRootsLoaded; + private long _hardRootsVersion; public McpClientRootsService(ICoreReplApp app) { @@ -55,24 +56,18 @@ public async ValueTask> GetAsync(CancellationToken return Current; } + long versionAtStart; lock (_syncRoot) { if (_hardRootsLoaded) { return _hardRoots; } - } - - var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken) - .ConfigureAwait(false); - var mappedRoots = result.Roots?.Select(MapRoot).ToArray() ?? []; - lock (_syncRoot) - { - _hardRoots = mappedRoots; - _hardRootsLoaded = true; - return _hardRoots; + versionAtStart = _hardRootsVersion; } + + return await GetAndMaybeCacheRootsAsync(server, versionAtStart, cancellationToken).ConfigureAwait(false); } public void SetSoftRoots(IEnumerable roots) @@ -120,11 +115,34 @@ public void HandleRootsListChanged() { _hardRoots = []; _hardRootsLoaded = false; + _hardRootsVersion++; } _app.InvalidateRouting(); } + private async ValueTask> GetAndMaybeCacheRootsAsync( + McpServer server, + long versionAtStart, + CancellationToken cancellationToken) + { + var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken) + .ConfigureAwait(false); + var mappedRoots = result.Roots?.Select(MapRoot).ToArray() ?? []; + + lock (_syncRoot) + { + if (_hardRootsVersion == versionAtStart) + { + _hardRoots = mappedRoots; + _hardRootsLoaded = true; + return _hardRoots; + } + + return mappedRoots; + } + } + private static McpClientRoot MapRoot(Root root) { var uri = Uri.TryCreate(root.Uri, UriKind.Absolute, out var parsed) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 8aa5010..eabd399 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -32,7 +32,8 @@ internal sealed class McpServerHandler private readonly Lock _attachLock = new(); private McpGeneratedSnapshot? _snapshot; - private bool _snapshotDirty = true; + private long _snapshotVersion = 1; + private long _builtSnapshotVersion; private McpServer? _server; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; @@ -277,7 +278,9 @@ private async ValueTask GetSnapshotAsync( { AttachServer(server); - if (!_snapshotDirty && _snapshot is { } cached) + var snapshotVersion = Volatile.Read(ref _snapshotVersion); + if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion + && _snapshot is { } cached) { return cached; } @@ -285,7 +288,9 @@ private async ValueTask GetSnapshotAsync( await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (!_snapshotDirty && _snapshot is { } refreshed) + snapshotVersion = Volatile.Read(ref _snapshotVersion); + if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion + && _snapshot is { } refreshed) { return refreshed; } @@ -296,7 +301,10 @@ private async ValueTask GetSnapshotAsync( await _roots.GetAsync(cancellationToken).ConfigureAwait(false); var built = BuildSnapshotCore(); _snapshot = built; - _snapshotDirty = false; + if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) + { + Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + } return built; } catch (OperationCanceledException) @@ -306,7 +314,10 @@ private async ValueTask GetSnapshotAsync( catch (Exception) when (previousSnapshot is not null) { _snapshot = previousSnapshot; - _snapshotDirty = false; + if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) + { + Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + } return previousSnapshot; } } @@ -355,15 +366,15 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) return; } - foreach (var tool in tools) + var collision = tools + .Select(static tool => tool.ProtocolTool.Name) + .FirstOrDefault(name => + string.Equals(name, DiscoverToolsName, StringComparison.OrdinalIgnoreCase) + || string.Equals(name, CallToolName, StringComparison.OrdinalIgnoreCase)); + if (collision is not null) { - var name = tool.ProtocolTool.Name; - if (string.Equals(name, DiscoverToolsName, StringComparison.OrdinalIgnoreCase) - || string.Equals(name, CallToolName, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"MCP tool name collision: '{name}' is reserved by DynamicToolCompatibility mode."); - } + throw new InvalidOperationException( + $"MCP tool name collision: '{collision}' is reserved by DynamicToolCompatibility mode."); } } @@ -435,7 +446,7 @@ private void EnsureRootsNotificationHandler(McpServer server) private void OnRoutingInvalidated() { - _snapshotDirty = true; + Interlocked.Increment(ref _snapshotVersion); lock (_refreshLock) { From d0b3e6820a0783997290cf67eaaa053526e30890 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 10:33:33 -0400 Subject: [PATCH 7/8] Re-arm MCP compatibility shim after routing changes --- src/Repl.Mcp/McpServerHandler.cs | 4 ++ .../Given_McpRootsAndDynamicTools.cs | 49 +++++++++++++++++++ src/Repl.McpTests/McpTestFixture.cs | 6 ++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index eabd399..27e06f2 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -447,6 +447,10 @@ private void EnsureRootsNotificationHandler(McpServer server) private void OnRoutingInvalidated() { Interlocked.Increment(ref _snapshotVersion); + if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) + { + Interlocked.Exchange(ref _compatibilityIntroServed, 0); + } lock (_refreshLock) { diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index 5f8d08b..ed70c18 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -145,6 +145,55 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd secondTools.Should().NotContain(t => string.Equals(t.Name, "discover_tools", StringComparison.Ordinal)); } + [TestMethod] + [Description("The compatibility shim is re-armed after routing invalidation so dynamic clients can bootstrap again.")] + public async Task When_RoutingChanges_AfterCompatibilityIntro_Then_ShimIsServedAgain() + { + var listChangedCount = 0; + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var fixture = await McpTestFixture.CreateAsync( + app => + { + app.Map("echo {msg}", (string msg) => $"echo:{msg}"); + }, + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + + await using var registration = fixture.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + var count = Interlocked.Increment(ref listChangedCount); + listChanged.TrySetResult(); + if (count >= 2) + { + return ValueTask.CompletedTask; + } + + return ValueTask.CompletedTask; + }); + + var initialTools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + initialTools.Select(static tool => tool.Name).Should().BeEquivalentTo( + ["discover_tools", "call_tool"]); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var steadyStateTools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + steadyStateTools.Should().Contain(t => string.Equals(t.Name, "echo", StringComparison.Ordinal)); + steadyStateTools.Should().NotContain(t => string.Equals(t.Name, "discover_tools", StringComparison.Ordinal)); + + listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.App.Map("added later", () => "added"); + fixture.App.Core.InvalidateRouting(); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var toolsAfterInvalidation = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + toolsAfterInvalidation.Select(static tool => tool.Name).Should().BeEquivalentTo( + ["discover_tools", "call_tool"]); + } + private sealed class RootAwareModule : IReplModule { public void Map(IReplMap app) diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 0999318..9fe5f52 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -17,14 +17,17 @@ internal sealed class McpTestFixture : IAsyncDisposable private readonly Pipe _clientToServer; private readonly Pipe _serverToClient; private readonly Task _serverTask; + private readonly ReplApp _app; private McpTestFixture( + ReplApp app, McpClient client, Task serverTask, CancellationTokenSource cts, Pipe clientToServer, Pipe serverToClient) { + _app = app; Client = client; _serverTask = serverTask; _cts = cts; @@ -32,6 +35,7 @@ private McpTestFixture( _serverToClient = serverToClient; } + public ReplApp App => _app; public McpClient Client { get; } public static Task CreateAsync(Action configure) => @@ -72,7 +76,7 @@ public static async Task CreateAsync( serverToClient.Reader.AsStream()); var client = await McpClient.CreateAsync(clientTransport, clientOptions).ConfigureAwait(false); - return new McpTestFixture(client, serverTask, cts, clientToServer, serverToClient); + return new McpTestFixture(app, client, serverTask, cts, clientToServer, serverToClient); } public async ValueTask DisposeAsync() From e4f5f7135322c0cd4e7b2a84ba2c3d9a3c2673ff Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 21 Mar 2026 11:03:37 -0400 Subject: [PATCH 8/8] Separate static MCP options from runtime handlers --- src/Repl.Mcp/McpReplExtensions.cs | 4 +- src/Repl.Mcp/McpServerHandler.cs | 43 ++++++++++++++++++- .../Given_McpServerOptionsBuilder.cs | 19 ++++---- src/Repl.McpTests/McpTestFixture.cs | 16 +++---- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/Repl.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index ac11ca1..78786cd 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -50,6 +50,8 @@ public static ReplApp UseMcpServer( /// Builds from the Repl app's command graph. /// Use this to integrate with custom transports (WebSocket, HTTP) or ASP.NET Core /// without going through the mcp serve CLI command. + /// The returned options capture the current command graph as pre-populated + /// collections. /// /// The core Repl app. /// Optional MCP configuration callback. @@ -66,7 +68,7 @@ public static McpServerOptions BuildMcpServerOptions( configure?.Invoke(options); var handler = new McpServerHandler(app, options, services ?? EmptyServiceProvider.Instance); - return handler.BuildServerOptions(); + return handler.BuildStaticServerOptions(); } private sealed class EmptyServiceProvider : IServiceProvider diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 27e06f2..cb46faa 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -66,7 +66,7 @@ public McpServerHandler( Justification = "MCP server handler runs in a context where all types are preserved.")] public async Task RunAsync(IReplIoContext io, CancellationToken ct) { - var serverOptions = BuildServerOptions(); + var serverOptions = BuildDynamicServerOptions(); var serverName = serverOptions.ServerInfo?.Name ?? "repl-mcp-server"; var transport = _options.TransportFactory is { } factory ? factory(serverName, io) @@ -92,7 +92,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } } - internal McpServerOptions BuildServerOptions() + internal McpServerOptions BuildDynamicServerOptions() { var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; var serverVersion = _options.ServerVersion ?? "1.0.0"; @@ -114,6 +114,22 @@ internal McpServerOptions BuildServerOptions() }; } + internal McpServerOptions BuildStaticServerOptions() + { + var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; + var serverVersion = _options.ServerVersion ?? "1.0.0"; + var snapshot = BuildSnapshotCore(); + + return new McpServerOptions + { + ServerInfo = new Implementation { Name = serverName, Version = serverVersion }, + Capabilities = BuildCapabilities(), + ToolCollection = ToCollection(snapshot.Tools), + ResourceCollection = ToResourceCollection(snapshot.Resources), + PromptCollection = ToCollection(snapshot.Prompts), + }; + } + internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => @@ -799,6 +815,29 @@ private bool IsToolCandidate(ReplDocCommand command) => && command.Annotations?.AutomationHidden != true && (_options.CommandFilter is not { } filter || filter(command)); + private static McpServerPrimitiveCollection ToCollection(IReadOnlyList items) + where T : IMcpServerPrimitive + { + var collection = new McpServerPrimitiveCollection(); + foreach (var item in items) + { + collection.Add(item); + } + + return collection; + } + + private static McpServerResourceCollection ToResourceCollection(IReadOnlyList items) + { + var collection = new McpServerResourceCollection(); + foreach (var item in items) + { + collection.Add(item); + } + + return collection; + } + internal sealed record McpGeneratedSnapshot( McpToolAdapter Adapter, List Tools, diff --git a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs index 329f68f..01e96fd 100644 --- a/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs +++ b/src/Repl.McpTests/Given_McpServerOptionsBuilder.cs @@ -10,8 +10,8 @@ namespace Repl.McpTests; public sealed class Given_McpServerOptionsBuilder { [TestMethod] - [Description("BuildMcpServerOptions wires the dynamic tools handler from the app's command graph.")] - public void When_AppHasCommands_Then_OptionsContainListToolsHandler() + [Description("BuildMcpServerOptions captures the current tools as a pre-populated collection.")] + public void When_AppHasCommands_Then_OptionsContainToolCollection() { var app = ReplApp.Create(); app.Map("greet {name}", (string name) => $"Hello, {name}!").ReadOnly(); @@ -19,21 +19,24 @@ public void When_AppHasCommands_Then_OptionsContainListToolsHandler() var options = app.Core.BuildMcpServerOptions(); - options.Handlers.ListToolsHandler.Should().NotBeNull(); - options.Handlers.CallToolHandler.Should().NotBeNull(); + options.ToolCollection.Should().NotBeNull(); + options.ToolCollection.Should().Contain(tool => string.Equals(tool.ProtocolTool.Name, "greet", StringComparison.Ordinal)); + options.ToolCollection.Should().Contain(tool => string.Equals(tool.ProtocolTool.Name, "ping", StringComparison.Ordinal)); } [TestMethod] - [Description("BuildMcpServerOptions wires the dynamic resources handler from ReadOnly commands.")] - public void When_AppHasReadOnlyCommands_Then_OptionsContainResourcesHandler() + [Description("BuildMcpServerOptions captures the current resources as a pre-populated collection.")] + public void When_AppHasReadOnlyCommands_Then_OptionsContainResourceCollection() { var app = ReplApp.Create(); app.Map("status", () => "ok").ReadOnly(); var options = app.Core.BuildMcpServerOptions(); - options.Handlers.ListResourcesHandler.Should().NotBeNull(); - options.Handlers.ReadResourceHandler.Should().NotBeNull(); + options.ResourceCollection.Should().NotBeNull(); + options.ResourceCollection.Should().ContainSingle(resource => + resource.ProtocolResource != null + && string.Equals(resource.ProtocolResource.Name, "status", StringComparison.Ordinal)); } [TestMethod] diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 9fe5f52..4f79424 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -54,22 +54,22 @@ public static async Task CreateAsync( configureOptions?.Invoke(options); var handler = new McpServerHandler(app.Core, options, EmptyServiceProvider.Instance); - var serverOptions = handler.BuildServerOptions(); var clientToServer = new Pipe(); var serverToClient = new Pipe(); var cts = new CancellationTokenSource(); - var serverName = serverOptions.ServerInfo?.Name ?? "test-server"; var inputStream = clientToServer.Reader.AsStream(); var outputStream = serverToClient.Writer.AsStream(); var ioContext = new PipeIoContext(inputStream, outputStream); - ITransport serverTransport = options.TransportFactory is { } factory - ? factory(serverName, ioContext) - : new StreamServerTransport(inputStream, outputStream, serverName); - - var server = McpServer.Create(serverTransport, serverOptions); - var serverTask = server.RunAsync(cts.Token); + 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(),