Skip to content
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; use call-now / poll-later patterns. |
| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. |
| `.AutomationHidden()` | Do not expose this command to MCP automation. |

Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP.
Expand Down
7 changes: 7 additions & 0 deletions docs/mcp-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ If your tool list is static, stay with the default setup from [mcp-overview.md](

## Client roots

> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the
> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it
> **for existing hosts and applications only** — new applications should not build on
> native MCP roots and can use [soft roots](#soft-roots-fallback) or explicit command
> parameters instead. See
> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture.

A **root** is a URI the client declares as being in scope for the session — typically an opened project folder, a working directory, or a boundary for what the agent should inspect or modify.

Roots give the server session-specific workspace context without inventing a custom protocol. When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`.
Expand Down
8 changes: 8 additions & 0 deletions docs/mcp-agent-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow.

> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the
> Sampling and Logging features that `IMcpSampling` and `IMcpFeedback` build on, and the
> SDK may remove them in a future version. Repl keeps supporting them **for existing hosts
> and applications only** — new applications should not adopt these interfaces directly and
> should prefer the portable `IReplInteractionChannel`, which degrades gracefully across
> CLI, REPL, hosted sessions, and MCP. See
> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture.

## Overview

Repl provides three MCP-oriented injectable interfaces:
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld();
| `.Destructive()` | Ask user for confirmation, sequential |
| `.Idempotent()` | Safe to retry, can parallelize |
| `.OpenWorld()` | Reaches external systems — expect latency and transient failures |
| `.LongRunning()` | Enables call-now/poll-later pattern |
| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns once Repl integrates the SDK Tasks extension — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) |
| `.AutomationHidden()` | Not visible to agents |

**Annotate every command exposed to agents.** Unannotated tools force agents to assume the worst: confirm everything, no parallelism, no retries.
Expand Down
6 changes: 6 additions & 0 deletions docs/mcp-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,12 @@ side-channel command output and are not included in `resources/read` bodies.

Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data.

### SDK and protocol versions

- Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently on the **2.0 line** (`2.0.0-preview.3`). The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts.
Comment thread
carldebilly marked this conversation as resolved.
- **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) ships experimentally in the SDK 2.0 line; 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).

| Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue |
|---|---|---|---|---|---|---|
| Tools | Yes | Yes | Yes | Yes | Yes | Yes |
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5"/>
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5"/>
<PackageVersion Include="ModelContextProtocol" Version="1.4.1"/>
<PackageVersion Include="ModelContextProtocol" Version="2.0.0-preview.3"/>
Comment thread
carldebilly marked this conversation as resolved.
<PackageVersion Include="Spectre.Console" Version="0.55.0"/>
</ItemGroup>

Expand Down
5 changes: 3 additions & 2 deletions src/Repl.Core/CommandAnnotations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ public sealed record CommandAnnotations
public bool OpenWorld { get; init; }

/// <summary>
/// Indicates the command may take a long time to complete.
/// Enables task-based execution in programmatic clients.
/// Indicates the command may take a long time to complete, so programmatic clients
/// should expect a slow call. Protocol-level task-based execution (MCP Tasks) is not
/// advertised until Repl integrates the SDK's Tasks extension.
/// </summary>
public bool LongRunning { get; init; }

Expand Down
7 changes: 7 additions & 0 deletions src/Repl.Mcp/IMcpFeedback.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
using ModelContextProtocol.Protocol;
using Repl.Interaction;

// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK
// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322,
// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange)
// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps
// supporting them until the SDK removes the surface (#51).
#pragma warning disable MCP9005

namespace Repl.Mcp;

/// <summary>
Expand Down
30 changes: 20 additions & 10 deletions src/Repl.Mcp/McpClientRootsService.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK
// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322,
// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange)
// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps
// supporting them until the SDK removes the surface (#51).
#pragma warning disable MCP9005

namespace Repl.Mcp;

// One instance PER SESSION (owned by McpSessionContext): hard roots, soft roots, and
// their cache/version state are session state — one handler can serve several sessions,
// and serving session A's roots to session B would expose A's workspace URIs and build
// B's root-dependent snapshot from the wrong workspace. Outbound transport still goes
// through the request-bound accessor (the destination is per request, finer than the
// session).
internal sealed class McpClientRootsService : IMcpClientRoots
{
private readonly ICoreReplApp _app;
private readonly McpRequestServerAccessor _servers;
private readonly Lock _syncRoot = new();
private McpServer? _server;
private McpClientRoot[] _hardRoots = [];
private McpClientRoot[] _softRoots = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope soft-root state per MCP session

When one handler serves two clients that lack native Roots support, this single _softRoots array is shared by both sessions: after client A calls SetSoftRoots, client B sees A's URI through Current and HasSoftRoots, and its soft-root-dependent modules can be enabled. This exposes one client's workspace context to another and contradicts the documented current-session semantics; store fallback roots per effective server/session just as hard roots are scoped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed independently on b094d055. Two sessions without native Roots shared one handler; A called SetSoftRoots(file:///root-a), then B called a separate soft_show tool and received "file:///root-a". This directly violates the current-session contract and leaks workspace context. Please key soft roots by the effective destination/session just like hard roots, including HasSoftRoots, Current, SetSoftRoots, and ClearSoftRoots.

private bool _hardRootsLoaded;
private long _hardRootsVersion;

public McpClientRootsService(ICoreReplApp app)
public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers)
{
_app = app;
_servers = servers;
}

public bool IsSupported => _server?.ClientCapabilities?.Roots is not null;
public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null;

public bool HasSoftRoots
{
Expand All @@ -42,15 +56,11 @@ public IReadOnlyList<McpClientRoot> Current
}
}

public void AttachServer(McpServer server)
{
ArgumentNullException.ThrowIfNull(server);
_server = server;
}

public async ValueTask<IReadOnlyList<McpClientRoot>> GetAsync(CancellationToken cancellationToken = default)
{
var server = _server;
// Single read: the effective server must not change between the support check and
// the roots request (a concurrent request re-binding the accessor must not be observed).
var server = _servers.Effective;
if (server?.ClientCapabilities?.Roots is null)
{
return Current;
Comment on lines +63 to 66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key cached roots by the destination session

When one McpServerHandler serves two root-capable sessions, this now resolves the correct request-bound server but still returns the single shared _hardRoots cache as soon as _hardRootsLoaded is true. Thus, if client A loads its roots first, a command invoked by client B receives A's root URIs without issuing roots/list to B; this can both expose another client's workspace roots and generate B's root-dependent tool snapshot from A's workspace. Keep the hard-root cache/version state per McpServer (or per session) rather than per handler.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed independently on 3cc415ae. A deterministic two-session probe gave client A root-a and client B root-b; after A populated the shared handler, B returned "root-a:file:///root-a" instead of its own root. This is a real cross-session data leak. The hard-roots state must be keyed by destination/session; because GetSnapshotAsync also caches one _snapshot for the handler, the root-dependent generated snapshot needs the same session affinity rather than only fixing the McpClientRootsService array.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit. Hard roots are now session state: cache entries are keyed by the destination server in a ConditionalWeakTable (weak keys — entries die with their session), with a global version stamp handling roots-list-changed invalidation (coarse but correct; the event is rare). Client B now performs its own roots/list round-trip and can no longer observe client A's workspace roots, and the root-dependent snapshot builds from the right workspace. Regression: When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots (RED observed with exactly your scenario — B received A's file:///ga).

Expand Down
14 changes: 6 additions & 8 deletions src/Repl.Mcp/McpElicitationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@ namespace Repl.Mcp;
/// a multi-field variant would build the <see cref="ElicitRequestParams.RequestSchema"/> with
/// multiple properties instead of one.
/// </remarks>
internal sealed class McpElicitationService : IMcpElicitation
internal sealed class McpElicitationService(McpRequestServerAccessor servers) : IMcpElicitation
{
private const string FieldName = "value";

private McpServer? _server;

public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null;
public bool IsSupported => servers.Effective?.ClientCapabilities?.Elicitation is not null;

public async ValueTask<string?> ElicitTextAsync(
string message,
Expand Down Expand Up @@ -99,19 +97,19 @@ internal sealed class McpElicitationService : IMcpElicitation
: null;
}

internal void AttachServer(McpServer server) => _server = server;

private async ValueTask<ElicitResult?> ElicitSingleFieldAsync(
string message,
ElicitRequestParams.PrimitiveSchemaDefinition schema,
CancellationToken cancellationToken)
{
if (!IsSupported)
// Single read: the effective server must not change between the support check and
// the call (a concurrent request re-binding the accessor must not be observed).
if (servers.Effective is not { ClientCapabilities.Elicitation: not null } server)
{
return null;
}

var result = await _server!.ElicitAsync(
var result = await server.ElicitAsync(
new ElicitRequestParams
{
Message = message,
Expand Down
30 changes: 18 additions & 12 deletions src/Repl.Mcp/McpFeedbackService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,45 @@
using ModelContextProtocol.Server;
using Repl.Interaction;

// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK
// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322,
// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange)
// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps
// supporting them until the SDK removes the surface (#51).
#pragma warning disable MCP9005

namespace Repl.Mcp;

/// <summary>
/// Internal implementation of <see cref="IMcpFeedback"/> backed by a live <see cref="McpServer"/> session.
/// </summary>
internal sealed class McpFeedbackService : IMcpFeedback
internal sealed class McpFeedbackService(McpRequestServerAccessor servers) : IMcpFeedback
{
private const string LoggerName = "repl.interaction";

private readonly AsyncLocal<ProgressToken?> _progressToken = new();
// This service is created per McpServerHandler/session and overlaid into the
// per-connection service provider, so the attached server reference is not shared
// across concurrent MCP connections.
private McpServer? _server;

public bool IsProgressSupported => _server is not null && _progressToken.Value is not null;
public bool IsProgressSupported => servers.Effective is not null && _progressToken.Value is not null;

public bool IsLoggingSupported => _server is not null;
public bool IsLoggingSupported => servers.Effective is not null;

public async ValueTask ReportProgressAsync(
ReplProgressEvent progress,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(progress);

if (!IsProgressSupported || progress.State == ReplProgressState.Clear || _progressToken.Value is not { } progressToken)
// Single read: the effective server must not change between the support check and
// the send (a concurrent request re-binding the accessor must not be observed).
if (servers.Effective is not { } server
|| progress.State == ReplProgressState.Clear
|| _progressToken.Value is not { } progressToken)
{
return;
}

var percent = progress.ResolvePercent();
await _server!.NotifyProgressAsync(
await server.NotifyProgressAsync(
progressToken,
new ProgressNotificationValue
{
Expand All @@ -52,12 +59,12 @@ public async ValueTask SendMessageAsync(
object? data,
CancellationToken cancellationToken = default)
{
if (!IsLoggingSupported)
if (servers.Effective is not { } server)
{
return;
}

await _server!.SendNotificationAsync(
await server.SendNotificationAsync(
NotificationMethods.LoggingMessageNotification,
new LoggingMessageNotificationParams
{
Expand All @@ -68,7 +75,6 @@ public async ValueTask SendMessageAsync(
cancellationToken: cancellationToken).ConfigureAwait(false);
}

internal void AttachServer(McpServer server) => _server = server;

internal IDisposable PushProgressToken(ProgressToken? progressToken) =>
new ProgressTokenScope(_progressToken, progressToken);
Expand Down
7 changes: 7 additions & 0 deletions src/Repl.Mcp/McpInteractionChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
using ModelContextProtocol.Server;
using Repl.Interaction;

// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK
// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322,
// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange)
// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps
// these features, so Repl keeps supporting them until the SDK removes the surface (#51).
#pragma warning disable MCP9005

namespace Repl.Mcp;

/// <summary>
Expand Down
31 changes: 31 additions & 0 deletions src/Repl.Mcp/McpRequestServerAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using ModelContextProtocol.Server;

namespace Repl.Mcp;

/// <summary>
/// Resolves the <see cref="McpServer"/> a capability call must target.
/// </summary>
/// <remarks>
/// SDK 2.0's <c>2026-07-28</c> protocol path hands each request a destination-bound
/// <see cref="McpServer"/>, and one handler can serve several sessions. The capability
/// services are singletons (exposed through DI to command handlers), so the effective
/// server must be the one bound to the FLOWING request — a shared mutable field would be
/// overwritten by whichever request attached last, cross-wiring capabilities between
/// concurrent calls. <see cref="AsyncLocal{T}"/> flows with the invocation and cannot
/// leak across requests; the session-level server remains the fallback for code running
/// outside a request (e.g. routing-change notifications).
/// </remarks>
internal sealed class McpRequestServerAccessor
{
private readonly AsyncLocal<McpServer?> _current = new();
private McpServer? _session;

/// <summary>Server for the flowing request, falling back to the session server.</summary>
public McpServer? Effective => _current.Value ?? _session;

/// <summary>Binds the flowing async context to the request's destination server.</summary>
public void BindRequest(McpServer server) => _current.Value = server;

/// <summary>Records the session-level server used outside request flows (null when the last session ends).</summary>
public void AttachSession(McpServer? server) => _session = server;
}
20 changes: 13 additions & 7 deletions src/Repl.Mcp/McpSamplingService.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK
// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322,
// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange)
// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps
// these features, so Repl keeps supporting them until the SDK removes the surface (#51).
#pragma warning disable MCP9005

namespace Repl.Mcp;

/// <summary>
/// Internal implementation of <see cref="IMcpSampling"/> backed by a live <see cref="McpServer"/> session.
/// </summary>
internal sealed class McpSamplingService : IMcpSampling
internal sealed class McpSamplingService(McpRequestServerAccessor servers) : IMcpSampling
{
private McpServer? _server;

public bool IsSupported => _server?.ClientCapabilities?.Sampling is not null;
public bool IsSupported => servers.Effective?.ClientCapabilities?.Sampling is not null;

public async ValueTask<string?> SampleAsync(
string prompt,
int maxTokens = 1024,
CancellationToken cancellationToken = default)
{
if (!IsSupported)
// Single read: the effective server must not change between the support check and
// the call (a concurrent request re-binding the accessor must not be observed).
if (servers.Effective is not { ClientCapabilities.Sampling: not null } server)
{
return null;
}

var result = await _server!.SampleAsync(
var result = await server.SampleAsync(
new CreateMessageRequestParams
{
Messages =
Expand All @@ -40,5 +47,4 @@ internal sealed class McpSamplingService : IMcpSampling
return result.Content?.OfType<TextContentBlock>().FirstOrDefault()?.Text;
}

internal void AttachServer(McpServer server) => _server = server;
}
Loading
Loading