Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/for-coding-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
28 changes: 28 additions & 0 deletions docs/mcp-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|---|---|---|
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5"/>
<PackageVersion Include="ModelContextProtocol" Version="2.2.0"/>
<PackageVersion Include="ModelContextProtocol.Extensions.Tasks" Version="2.2.0"/>
<PackageVersion Include="Spectre.Console" Version="0.55.0"/>
</ItemGroup>

Expand Down
46 changes: 44 additions & 2 deletions src/Repl.Mcp/McpServerHandler.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<IConfigureOptions<McpServerOptions>> _taskConfigurators;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _longRunningToolNames =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _refreshLock = new();
private readonly Lock _attachLock = new();

Expand Down Expand Up @@ -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<IConfigureOptions<McpServerOptions>>()
.ToArray();
}

private McpSessionContext CreateSessionContext()
Expand Down Expand Up @@ -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(),
Expand All @@ -184,6 +205,8 @@ internal McpServerOptions BuildDynamicServerOptions()
GetPromptHandler = GetPromptAsync,
},
};
ConfigureTasks(serverOptions);
return serverOptions;
}

internal McpServerOptions BuildStaticServerOptions()
Expand All @@ -192,14 +215,16 @@ 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(),
ToolCollection = ToCollection(snapshot.Tools),
ResourceCollection = ToResourceCollection(snapshot.Resources),
PromptCollection = ToCollection(snapshot.Prompts),
};
ConfigureTasks(serverOptions);
return serverOptions;
}

internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(CreateSessionContext());
Expand Down Expand Up @@ -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<ReplMcpServerTool>().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<CallToolRequestParams> 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.
Expand Down
1 change: 1 addition & 0 deletions src/Repl.Mcp/Repl.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<ItemGroup>
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>

<ItemGroup>
Expand Down
8 changes: 8 additions & 0 deletions src/Repl.Mcp/ReplMcpServerOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Extensions.Tasks;
using Repl.Documentation;

namespace Repl.Mcp;
Expand Down Expand Up @@ -97,6 +98,13 @@ public sealed class ReplMcpServerOptions
/// </summary>
public DynamicToolCompatibilityMode DynamicToolCompatibility { get; set; } = DynamicToolCompatibilityMode.Disabled;

/// <summary>
/// Optional store used for MCP Tasks created by <c>.LongRunning()</c> commands.
/// When <c>null</c>, 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.
/// </summary>
public IMcpTaskStore? TaskStore { get; set; }

private readonly List<McpPromptRegistration> _prompts = [];
private readonly List<McpAppResourceRegistration> _uiResources = [];

Expand Down
12 changes: 7 additions & 5 deletions src/Repl.Mcp/ReplMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,6 +40,9 @@ public ReplMcpServerTool(
/// <inheritdoc />
public override Tool ProtocolTool => _protocolTool;

/// <summary>Whether this tool opts into the modern MCP Tasks runtime.</summary>
internal bool IsLongRunning => _isLongRunning;

/// <inheritdoc />
public override IReadOnlyList<object> Metadata { get; } = [];

Expand Down
48 changes: 47 additions & 1 deletion src/Repl.McpTests/Given_McpInspectorCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading