diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 3887440..a4c2ab0 100644 --- a/docs/for-coding-agents.md +++ b/docs/for-coding-agents.md @@ -142,7 +142,7 @@ Use these annotations to help agents make safer decisions: | `.Destructive()` | May delete or mutate important state; ask for confirmation. | | `.Idempotent()` | Safe to retry. | | `.OpenWorld()` | Talks to external systems; expect latency and failures. | -| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. | +| `.LongRunning()` | May take time. Capable modern MCP clients use Tasks; other clients receive the normal synchronous result. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | | `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. | diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index 90ea3f6..8ce9d9b 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -180,6 +180,34 @@ Even a weak client can then discover the real tools manually and call them via ` | Client supports tools but misses dynamic refreshes | Enable `DiscoverAndCallShim` | | Client has both issues | Use soft roots and the dynamic tool shim | +## Tasks for long-running commands + +`.LongRunning()` opts a tool into the modern MCP Tasks extension: + +```csharp +app.Map("deploy", DeployAsync) + .LongRunning(); +``` + +For a client that negotiates the `io.modelcontextprotocol/tasks` extension, the call creates a task. The client can poll it with `tasks/get` and cancel it with `tasks/cancel`; the command's `CancellationToken` is cancelled too. Other tools remain synchronous. + +Clients that do not negotiate the extension — including legacy clients — receive the normal synchronous `tools/call` result. Repl does not implement the retired 2025 Tasks wire format (`Tool.Execution` / `taskSupport`); it uses the current extension only. + +By default Repl keeps task state in an in-memory store, which is the right fit for a stdio server process. For a stateless HTTP server, multiple server instances, or task recovery after a restart, supply a shared durable implementation of `IMcpTaskStore`: + +```csharp +using ModelContextProtocol.Extensions.Tasks; + +app.UseMcpServer(options => +{ + options.TaskStore = taskStore; +}); +``` + +`taskStore` must be safe for concurrent access and outlive the individual request. A task can be cancelled by a capable client, but a process shutdown still cancels any in-memory work. + +> **Interaction limitation:** the current official SDK cannot compose MCP's multi-round-trip requests (MRTR) with a task-backed `McpServerTool`. Keep task-backed commands non-interactive for now. A client that falls back to synchronous execution can still use Repl's existing interaction features. + ## MCP Apps advanced patterns For the basic MCP Apps setup, start with [mcp-overview.md](mcp-overview.md#mcp-apps) and [mcp-reference.md](mcp-reference.md#mcp-apps). This section covers patterns for more complex UIs. diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index ac42537..8502384 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -64,7 +64,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld(); | `.Destructive()` | Ask user for confirmation, sequential | | `.Idempotent()` | Safe to retry, can parallelize | | `.OpenWorld()` | Reaches external systems — expect latency and transient failures | -| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns once Repl integrates the SDK Tasks extension — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) | +| `.LongRunning()` | Uses MCP Tasks with capable modern clients; other clients receive the normal synchronous result. See [advanced configuration](mcp-advanced.md#tasks-for-long-running-commands). | | `.AutomationHidden()` | Not visible to agents | **Annotate every command exposed to agents.** Unannotated tools force agents to assume the worst: confirm everything, no parallelism, no retries. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index c6e6f34..3cb2e12 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -516,7 +516,7 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a - Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently at **2.2.0**. The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. - **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) is available starting with SDK 2.0; Repl has not adopted it yet. -- **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72). +- **MCP Tasks**: `.LongRunning()` uses the SDK's `ModelContextProtocol.Extensions.Tasks` extension and its current `tasks/get` / `tasks/update` / `tasks/cancel` lifecycle. Clients that do not negotiate the extension keep the normal synchronous `tools/call` behavior. Repl intentionally does not implement the retired per-tool `Tool.Execution` / `taskSupport` wire format; see [MCP Tasks for long-running commands](mcp-advanced.md#tasks-for-long-running-commands). | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index fc66d5a..ae916cb 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 5cbf9da..c6739bc 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; @@ -31,6 +34,11 @@ internal sealed class McpServerHandler private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; private readonly McpFeedbackService _feedback; + private readonly IMcpTaskStore _taskStore; + private readonly IServiceProvider _taskServices; + private readonly IReadOnlyList> _taskConfigurators; + private readonly System.Collections.Concurrent.ConcurrentDictionary _longRunningToolNames = + new(StringComparer.OrdinalIgnoreCase); private readonly Lock _refreshLock = new(); private readonly Lock _attachLock = new(); @@ -71,6 +79,19 @@ public McpServerHandler( _sampling = new McpSamplingService(_requestServers); _elicitation = new McpElicitationService(_requestServers); _feedback = new McpFeedbackService(_requestServers); + _taskStore = options.TaskStore + ?? services.GetService(typeof(IMcpTaskStore)) as IMcpTaskStore + ?? new InMemoryMcpTaskStore(); + + var taskServices = new ServiceCollection(); + taskServices + .AddMcpServer() + .WithTasks(_taskStore, taskOptions => + taskOptions.ExecutionModeSelector = ResolveTaskExecutionMode); + _taskServices = taskServices.BuildServiceProvider(); + _taskConfigurators = _taskServices + .GetServices>() + .ToArray(); } private McpSessionContext CreateSessionContext() @@ -169,7 +190,7 @@ internal McpServerOptions BuildDynamicServerOptions() _ = CreateDocumentationModel(CreateSessionContext().Services); } - return new McpServerOptions + var serverOptions = new McpServerOptions { ServerInfo = new Implementation { Name = serverName, Version = serverVersion }, Capabilities = BuildCapabilities(), @@ -184,6 +205,8 @@ internal McpServerOptions BuildDynamicServerOptions() GetPromptHandler = GetPromptAsync, }, }; + ConfigureTasks(serverOptions); + return serverOptions; } internal McpServerOptions BuildStaticServerOptions() @@ -192,7 +215,7 @@ internal McpServerOptions BuildStaticServerOptions() var serverVersion = _options.ServerVersion ?? "1.0.0"; var snapshot = BuildSnapshotCore(CreateSessionContext()); - return new McpServerOptions + var serverOptions = new McpServerOptions { ServerInfo = new Implementation { Name = serverName, Version = serverVersion }, Capabilities = BuildCapabilities(), @@ -200,6 +223,8 @@ internal McpServerOptions BuildStaticServerOptions() ResourceCollection = ToResourceCollection(snapshot.Resources), PromptCollection = ToCollection(snapshot.Prompts), }; + ConfigureTasks(serverOptions); + return serverOptions; } internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(CreateSessionContext()); @@ -472,12 +497,29 @@ private McpGeneratedSnapshot BuildSnapshotCore(McpSessionContext context) command => command, StringComparer.OrdinalIgnoreCase); var tools = GenerateAllTools(model, adapter, _separator, commandsByPath); + foreach (var tool in tools.OfType().Where(static tool => tool.IsLongRunning)) + { + _longRunningToolNames.TryAdd(tool.ProtocolTool.Name, 0); + } ValidateCompatibilityToolNames(tools); var resources = GenerateResources(model, adapter, _separator, commandsByPath, context.Services); var prompts = CollectPrompts(model, adapter, _separator); return new McpGeneratedSnapshot(adapter, tools, resources, prompts); } + private void ConfigureTasks(McpServerOptions serverOptions) + { + foreach (var configurator in _taskConfigurators) + { + configurator.Configure(serverOptions); + } + } + + private McpTaskExecutionMode ResolveTaskExecutionMode(RequestContext request) => + request.Params?.Name is { } toolName && _longRunningToolNames.ContainsKey(toolName) + ? McpTaskExecutionMode.Optional + : McpTaskExecutionMode.Synchronous; + // The documentation model resolves module-presence predicates against the SESSION's // services (e.g. IMcpClientRoots), so the model — and everything generated from it — // reflects the capabilities of the session it is built for. diff --git a/src/Repl.Mcp/Repl.Mcp.csproj b/src/Repl.Mcp/Repl.Mcp.csproj index 32299b9..cfba286 100644 --- a/src/Repl.Mcp/Repl.Mcp.csproj +++ b/src/Repl.Mcp/Repl.Mcp.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Repl.Mcp/ReplMcpServerOptions.cs b/src/Repl.Mcp/ReplMcpServerOptions.cs index 6f363fe..c3f4a2a 100644 --- a/src/Repl.Mcp/ReplMcpServerOptions.cs +++ b/src/Repl.Mcp/ReplMcpServerOptions.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using ModelContextProtocol.Extensions.Tasks; using Repl.Documentation; namespace Repl.Mcp; @@ -97,6 +98,13 @@ public sealed class ReplMcpServerOptions /// public DynamicToolCompatibilityMode DynamicToolCompatibility { get; set; } = DynamicToolCompatibilityMode.Disabled; + /// + /// Optional store used for MCP Tasks created by .LongRunning() commands. + /// When null, Repl uses an in-memory store suitable for a single stdio server process. + /// Configure a durable, shared store for stateless HTTP or work that must survive process restarts. + /// + public IMcpTaskStore? TaskStore { get; set; } + private readonly List _prompts = []; private readonly List _uiResources = []; diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index a637677..c40af7c 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -13,18 +13,17 @@ internal sealed class ReplMcpServerTool : McpServerTool { private readonly McpToolAdapter _adapter; private readonly Tool _protocolTool; + private readonly bool _isLongRunning; - // SDK 2.0 extracted MCP Tasks into ModelContextProtocol.Extensions.Tasks (store, task - // results, client polling) and dropped the per-tool Tool.Execution / ToolTaskSupport - // augmentation from the protocol surface. Repl keeps .LongRunning() in its own model - // (help/docs) and deliberately does not advertise task support until Repl integrates - // the Tasks extension end-to-end (tasks/get|update|cancel) — tracked in issue #72. + // The modern MCP Tasks extension selects the execution mode from this marker. The + // wire protocol no longer carries the retired per-tool Tool.Execution augmentation. public ReplMcpServerTool( ReplDocCommand command, string toolName, McpToolAdapter adapter) { _adapter = adapter; + _isLongRunning = command.Annotations?.LongRunning == true; _protocolTool = new Tool { Name = toolName, @@ -41,6 +40,9 @@ public ReplMcpServerTool( /// public override Tool ProtocolTool => _protocolTool; + /// Whether this tool opts into the modern MCP Tasks runtime. + internal bool IsLongRunning => _isLongRunning; + /// public override IReadOnlyList Metadata { get; } = []; diff --git a/src/Repl.McpTests/Given_McpInspectorCli.cs b/src/Repl.McpTests/Given_McpInspectorCli.cs index 2b3c95a..28c8284 100644 --- a/src/Repl.McpTests/Given_McpInspectorCli.cs +++ b/src/Repl.McpTests/Given_McpInspectorCli.cs @@ -7,7 +7,7 @@ namespace Repl.McpTests; public sealed class Given_McpInspectorCli { private const string EnableInspectorSmokeVariable = "REPL_RUN_MCP_INSPECTOR_TESTS"; - private const string InspectorPackage = "@modelcontextprotocol/inspector@0.22.0"; + private const string InspectorPackage = "@modelcontextprotocol/inspector@2.2.0"; [TestMethod] [TestCategory("ExternalToolchain")] @@ -52,6 +52,46 @@ public async Task When_InspectorReadsCommandBackedResource_Then_MimeTypeMatchesJ resourceText.RootElement.ValueKind.Should().NotBe(JsonValueKind.Undefined); } + [TestMethod] + [TestCategory("ExternalToolchain")] + [Description("Opt-in end-to-end compatibility guard: the official MCP Inspector can call a long-running tool and receives the synchronous fallback when it does not negotiate MCP Tasks.")] + public async Task When_InspectorCallsLongRunningTool_Then_ServerReturnsSynchronousFallback() + { + if (!IsInspectorSmokeEnabled()) + { + Assert.Inconclusive( + $"Set {EnableInspectorSmokeVariable}=1 to run the MCP Inspector external-toolchain smoke test."); + } + + var npx = ResolveExecutable(OperatingSystem.IsWindows() ? "npx.cmd" : "npx") + ?? throw new InvalidOperationException( + $"{EnableInspectorSmokeVariable}=1 was set, but npx was not found on PATH."); + var dotnet = ResolveExecutable(OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet") + ?? throw new InvalidOperationException( + $"{EnableInspectorSmokeVariable}=1 was set, but dotnet was not found on PATH."); + var serverDll = ResolveSampleServerDll(); + + var responseJson = await RunInspectorAsync( + npx, + dotnet, + serverDll, + ["--method", "tools/call", "--tool-name", "feedback_demo", "--format", "json"]) + .ConfigureAwait(false); + + using var response = JsonDocument.Parse(responseJson); + var content = response.RootElement + .GetProperty("result") + .GetProperty("content") + .EnumerateArray() + .Single() + .GetProperty("text") + .GetString(); + using var result = JsonDocument.Parse(content ?? string.Empty); + + result.RootElement.GetProperty("kind").GetString().Should().Be("success"); + result.RootElement.GetProperty("message").GetString().Should().Be("Feedback demo completed."); + } + private static bool IsInspectorSmokeEnabled() { var value = Environment.GetEnvironmentVariable(EnableInspectorSmokeVariable); @@ -138,6 +178,12 @@ private static void ApplyMinimalEnvironment(ProcessStartInfo startInfo) CopyEnvironmentVariable(startInfo, "PATH"); CopyEnvironmentVariable(startInfo, "HOME"); CopyEnvironmentVariable(startInfo, "USERPROFILE"); + CopyEnvironmentVariable(startInfo, "APPDATA"); + CopyEnvironmentVariable(startInfo, "LOCALAPPDATA"); + CopyEnvironmentVariable(startInfo, "COMSPEC"); + CopyEnvironmentVariable(startInfo, "SystemRoot"); + CopyEnvironmentVariable(startInfo, "WINDIR"); + CopyEnvironmentVariable(startInfo, "PATHEXT"); CopyEnvironmentVariable(startInfo, "TMPDIR"); CopyEnvironmentVariable(startInfo, "TMP"); CopyEnvironmentVariable(startInfo, "TEMP"); diff --git a/src/Repl.McpTests/Given_McpTasks.cs b/src/Repl.McpTests/Given_McpTasks.cs new file mode 100644 index 0000000..56870c9 --- /dev/null +++ b/src/Repl.McpTests/Given_McpTasks.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpTasks +{ + [TestMethod] + [Description("A .LongRunning() Repl command uses the official Tasks extension: the initial call creates a task and polling returns its completed tool result.")] + public async Task When_LongRunningCommandIsCalledByModernClient_Then_ResultIsAvailableThroughTaskPolling() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("deploy", async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + return "deployed"; + }).LongRunning(); + }); + + _ = await fixture.Client.ListToolsAsync(); + var started = await fixture.Client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "deploy" }); + + started.IsTask.Should().BeTrue(); + started.TaskCreated.Should().NotBeNull(); + started.TaskCreated!.Status.Should().Be(McpTaskStatus.Working); + + var completed = await WaitForCompletionAsync(fixture.Client, started.TaskCreated.TaskId); + ReadJsonString(completed.Result.GetProperty("content")[0].GetProperty("text").GetString()).Should().Be("deployed"); + } + + [TestMethod] + [Description("A modern Tasks-capable client still receives a normal immediate response for a Repl command not marked .LongRunning().")] + public async Task When_RegularCommandIsCalledByModernClient_Then_ItRemainsSynchronous() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map("status", () => "ready")); + + _ = await fixture.Client.ListToolsAsync(); + var invocation = await fixture.Client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "status" }); + + invocation.IsTask.Should().BeFalse(); + invocation.Result!.Content.Should().ContainSingle(); + ReadJsonString(((TextContentBlock)invocation.Result.Content[0]).Text).Should().Be("ready"); + } + + [TestMethod] + [Description("A legacy 2025-11-25 client invokes a long-running Repl command synchronously and is never given a modern task handle.")] + public async Task When_LongRunningCommandIsCalledByLegacyClient_Then_ItFallsBackToSynchronousInvocation() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("deploy", () => "deployed").LongRunning(), + configureOptions: null, + clientOptions: new McpClientOptions { ProtocolVersion = "2025-11-25" }); + + var result = await fixture.Client.CallToolAsync(new CallToolRequestParams { Name = "deploy" }); + + result.Content.Should().ContainSingle(); + ReadJsonString(((TextContentBlock)result.Content[0]).Text).Should().Be("deployed"); + } + + [TestMethod] + [Description("Cancelling a running Repl task transitions it to cancelled and forwards cancellation to the command.")] + public async Task When_ModernClientCancelsLongRunningCommand_Then_TaskIsCancelled() + { + var commandStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("wait", async (CancellationToken cancellationToken) => + { + commandStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return "unreachable"; + }).LongRunning(); + }); + + _ = await fixture.Client.ListToolsAsync(); + var started = await fixture.Client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "wait" }); + started.IsTask.Should().BeTrue(); + + await commandStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await fixture.Client.CancelTaskAsync(started.TaskCreated!.TaskId); + + var task = await fixture.Client.GetTaskAsync(started.TaskCreated.TaskId); + task.Should().BeOfType(); + } + + private static async Task WaitForCompletionAsync(McpClient client, string taskId) + { + for (var attempt = 0; attempt < 40; attempt++) + { + var task = await client.GetTaskAsync(taskId).ConfigureAwait(false); + if (task is CompletedTaskResult completed) + { + return completed; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); + } + + throw new TimeoutException($"Task '{taskId}' did not complete within one second."); + } + + private static string? ReadJsonString(string? json) + { + using var document = JsonDocument.Parse(json ?? "null"); + return document.RootElement.GetString(); + } +}