diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md
index 0e61fba8..3887440a 100644
--- a/docs/for-coding-agents.md
+++ b/docs/for-coding-agents.md
@@ -142,7 +142,7 @@ Use these annotations to help agents make safer decisions:
| `.Destructive()` | May delete or mutate important state; ask for confirmation. |
| `.Idempotent()` | Safe to retry. |
| `.OpenWorld()` | Talks to external systems; expect latency and failures. |
-| `.LongRunning()` | May take time; use call-now / poll-later patterns. |
+| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. |
| `.AutomationHidden()` | Do not expose this command to MCP automation. |
| `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. |
diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md
index f82ebdd9..bfe1487a 100644
--- a/docs/mcp-advanced.md
+++ b/docs/mcp-advanced.md
@@ -20,6 +20,18 @@ If your tool list is static, stay with the default setup from [mcp-overview.md](
## Client roots
+> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the
+> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it
+> **for existing hosts and applications only.** New applications should take the workspace as an
+> **explicit command parameter**, or mint a handle from a setup command and pass it back — that is
+> what SEP-2567 prescribes now that the protocol has no sessions to hang such state on. See
+> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture.
+>
+> [Soft roots](#soft-roots-fallback) are **not** the modern answer: they are the same
+> connection-scoped state by another name, and they are scoped to the process rather than the
+> connection when a host reuses one `BuildMcpServerOptions()` result. Treat them as a legacy
+> compatibility feature for clients that lack native roots.
+
A **root** is a URI the client declares as being in scope for the session — typically an opened project folder, a working directory, or a boundary for what the agent should inspect or modify.
Roots give the server session-specific workspace context without inventing a custom protocol. When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`.
@@ -39,10 +51,10 @@ app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) =
| Member | Meaning |
|---|---|
| `IsSupported` | The connected client supports native MCP roots |
-| `Current` | Current effective roots for the session |
+| `Current` | Roots already resolved for the current scope — the session under `mcp serve`, this request on a reused `BuildMcpServerOptions()` result, where it is empty until `GetAsync` has been called. Under `mcp serve`, soft roots stand in while nothing native has been resolved, so an empty answer means the roots in force are empty rather than unresolved; call `GetAsync` when you need the failure itself |
| `GetAsync()` | Refreshes native roots if supported |
| `HasSoftRoots` | Fallback roots were initialized manually |
-| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots for the current session |
+| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots — per connection under `mcp serve`, per process when a host reuses one `BuildMcpServerOptions()` result |
> **Why `IMcpClientRoots` is MCP-only:** Roots are session-scoped MCP data. They don't make sense as a generic `Repl.Core` concept for terminal or non-MCP execution. That's why the interface lives in `Repl.Mcp` and is injected only for MCP sessions.
@@ -50,6 +62,12 @@ app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) =
Because `IMcpClientRoots` is injectable, you can use it in command handlers and in module presence predicates. That lets you expose tools only when a certain MCP capability or session state is available.
+> **On revision `2026-07-28` this stops varying by client.** Discovery there runs presence predicates
+> against fixed answers — capability checks read as supported, soft roots as absent, the root list as
+> empty — so whatever the predicate returns is what every client is offered. The predicate still runs
+> normally on the earlier revisions and outside MCP. See
+> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands).
+
```csharp
using Repl.Mcp;
@@ -60,12 +78,14 @@ app.MapModule(
> **How this works internally:** The MCP integration builds its documentation model and MCP surfaces using the current MCP session service provider, not just the app root service provider. This makes session-scoped services like `IMcpClientRoots` visible to module presence predicates, tool handlers, prompt handlers, and resource handlers.
-Typical session-aware conditions:
+Typical session-aware conditions, with what each becomes on `2026-07-28`:
-- Roots are available
-- Soft roots were initialized
-- The current tenant or login is known
-- A module should appear only for one agent session
+| Condition | On `2026-07-28` |
+| --- | --- |
+| Roots are available | Always true, so the module is advertised to every client |
+| Soft roots were initialized | Always false, so the module is advertised to none |
+| The current tenant or login is known | Unchanged — application state, not a per-connection MCP answer |
+| A module should appear only for one agent session | Not expressible: the advertised set must not vary per connection |
### MCP-only vs workspace-aware commands
@@ -79,6 +99,9 @@ app.MapModule(
Use when: the command helps an agent initialize MCP session state or depends directly on MCP capabilities.
+This gate asks whether the service exists at all rather than what it answers, so it is unaffected by
+the fixed answers above: it stays true inside MCP on every revision and false outside it.
+
**Pattern 2: Workspace-aware** — the command works both inside and outside MCP:
```csharp
@@ -113,6 +136,11 @@ app.MapModule(
new WorkspaceModule(),
(IMcpClientRoots roots) => roots.IsSupported || roots.HasSoftRoots);
+// On revision 2026-07-28 both predicates above resolve to constants during discovery:
+// IsSupported answers true and HasSoftRoots answers false, so SoftRootsInitModule is
+// advertised to no client and WorkspaceModule to every client. Map the bootstrap module
+// unconditionally if you serve that revision — see docs/mcp-conformance.md.
+
sealed class SoftRootsInitModule : IReplModule
{
public void Map(IReplMap app)
@@ -148,6 +176,12 @@ app.UseMcpServer(o =>
});
```
+> **Initialize-era only.** On `2026-07-28` the bootstrap does not run and the first `tools/list`
+> already returns the real catalog: that revision forbids the advertised set from changing as a side
+> effect of another request on the connection, which is exactly what the two-step bootstrap does. The
+> shim exists for clients that do not refresh on `list_changed`, and those are initialize-era clients.
+> See [Conformance](mcp-conformance.md#what-differs-by-revision).
+
When enabled:
1. The first `tools/list` returns only `discover_tools` and `call_tool`
diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md
index cac9ce4e..7cc3c46e 100644
--- a/docs/mcp-agent-capabilities.md
+++ b/docs/mcp-agent-capabilities.md
@@ -8,14 +8,22 @@
See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow.
+> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the
+> Sampling and Logging features that `IMcpSampling` and `IMcpFeedback` build on, and the
+> SDK may remove them in a future version. Repl keeps supporting them **for existing hosts
+> and applications only** — new applications should not adopt these interfaces directly and
+> should prefer the portable `IReplInteractionChannel`, which degrades gracefully across
+> CLI, REPL, hosted sessions, and MCP. See
+> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture.
+
## Overview
Repl provides three MCP-oriented injectable interfaces:
| Interface | MCP capability | What it does |
|---|---|---|
-| `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-05/client/sampling) | Ask the connected LLM to generate a completion |
-| `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-05/client/elicitation) | Ask the user for structured input through the agent client |
+| `IMcpSampling` | [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) | Ask the connected LLM to generate a completion |
+| `IMcpElicitation` | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) | Ask the user for structured input through the agent client |
| `IMcpFeedback` | Progress + logging/message notifications | Send MCP-specific runtime feedback during a tool call |
They work like `IMcpClientRoots` — inject them into any command handler, check capability flags, and use them. They are automatically excluded from MCP tool schemas.
@@ -238,12 +246,15 @@ public interface IMcpFeedback
CancellationToken cancellationToken = default);
ValueTask SendMessageAsync(
- LoggingLevel level,
+ McpMessageLevel level,
object? data,
CancellationToken cancellationToken = default);
}
```
+`McpMessageLevel` is Repl's own enum (`Debug` … `Emergency`), so this signature does not expose the
+SDK's deprecated `LoggingLevel` to your build.
+
Use it when:
- you need to control MCP progress/message notifications directly
@@ -256,10 +267,13 @@ Use it when:
app.Map("sync contacts",
async (IMcpFeedback feedback, CancellationToken ct) =>
{
- if (feedback.IsLoggingSupported)
- {
- await feedback.SendMessageAsync(LoggingLevel.Info, "Starting sync.", ct);
- }
+ // Sending unconditionally is fine: a message the client cannot receive as a notification
+ // is carried back in the tool result instead. Check IsLoggingSupported only when you want
+ // to skip work that would otherwise be wasted — it reports that a threshold exists, not
+ // that this particular message clears it.
+ // That buffer keeps every level, including Debug: it exists only for a request that declared
+ // no log level, and it is not filtered. Internal diagnostics belong on ILogger, not here.
+ await feedback.SendMessageAsync(McpMessageLevel.Info, "Starting sync.", ct);
if (feedback.IsProgressSupported)
{
@@ -322,7 +336,11 @@ if (!elicitation.IsSupported)
For `IMcpFeedback`, the same idea applies:
- check `IsProgressSupported` before sending MCP-only progress directly
-- check `IsLoggingSupported` before sending MCP-only messages directly
+- `IsLoggingSupported` tells you whether a message would arrive as a **notification**; it is `false`
+ on `2026-07-28` unless the request declared a log level. A message sent anyway rides back in the
+ tool or prompt result, so treat it as a hint rather than a gate — with one exception: a resource
+ read that **succeeds** keeps only its body, whose type is advertised, and drops the feedback on
+ purpose. A read that fails carries it in the surfaced error
- prefer `IReplInteractionChannel` when the feedback should still render well outside MCP
## Client compatibility
diff --git a/docs/mcp-conformance.md b/docs/mcp-conformance.md
new file mode 100644
index 00000000..08b9834c
--- /dev/null
+++ b/docs/mcp-conformance.md
@@ -0,0 +1,142 @@
+# MCP Specification Conformance
+
+> **This page is for you if** you need to know exactly what Repl guarantees on a given MCP protocol revision, or why a behaviour differs between two clients.
+>
+> **Purpose:** One place to answer "what does Repl do on revision X". Every claim names the regression test that pins it.
+> **Prerequisite:** [MCP overview](mcp-overview.md)
+> **Related:** [Reference](mcp-reference.md) · [Transports](mcp-transports.md) · [Module presence](module-presence.md)
+
+## Supported revisions
+
+| Revision | Era | How Repl serves it |
+| --- | --- | --- |
+| `2026-07-28` | Modern — version, identity and capabilities travel as per-request `_meta`; there is no session | Default for an SDK client that does not pin a version |
+| `2025-11-25` | Legacy — a session is established by an `initialize` handshake | Served when the client pins it, or opens with `initialize` |
+
+Repl is a dual-era server: a request carrying modern `_meta` is served statelessly, and an
+`initialize` request selects legacy semantics. Pinned by
+`Given_McpIntegration.When_ClientPinsLegacyProtocolVersion_Then_InitializeHandshakeAndToolsWork`.
+
+The era is a property of the request, not of the transport. **Statelessness is not an HTTP mode**: on
+`2026-07-28` stdio has no session either, even though the process and its pipe outlive many requests.
+Repl still caches per connection — that is an optimisation the protocol says nothing about — but
+nothing a client can *observe* may depend on the connection.
+
+## What differs by revision
+
+| Behaviour | `2025-11-25` | `2026-07-28` | Pinned by |
+| --- | --- | --- | --- |
+| Advertised tool set may vary by client capabilities | Yes | **No** — invariant | `Given_McpConcurrentSessions.When_LegacySessionsShareAGatedGraph_Then_EachSeesItsOwnTools` / `...When_ModernSessionsShareAGatedGraph_Then_TheAdvertisedSetIsInvariant` |
+| Soft roots reveal commands | Yes | **No** — they still resolve at execution | `Given_McpRootsAndDynamicTools.When_LegacyClientDoesNotSupportRoots_Then_SoftRootsCanInitializeWorkspace` / `...When_ModernClientSetsSoftRoots_Then_TheyResolveWithoutChangingTheAdvertisedSet` |
+| Compatibility bootstrap (`DynamicToolCompatibilityMode.DiscoverAndCallShim`) | First `tools/list` answers `discover_tools` / `call_tool`, the next the real catalog | Not served — the real catalog from the first request | `Given_McpConcurrentSessions.When_ShimEnabledAndTwoLegacySessionsList_Then_EachSessionGetsTheIntro` / `...When_ShimEnabledAndAModernSessionLists_Then_TheCatalogIsTheSameEveryTime` |
+| Feedback on a failed tool, prompt or resource | Emitted as notifications | Carried in the surfaced error | `Given_McpUserFeedback.When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError` / `...When_AFailingResourceReadDeclaresNoLogLevel_Then_FeedbackRidesInTheError` / `Given_McpApps.When_AFailingUiResourceReadEmitsFeedback_Then_ItRidesInTheError` |
+| `cacheScope` / `ttlMs` on list results | Absent — not in the schema | Set to private, zero TTL | `Given_McpIntegration.When_ClientPinsLegacyProtocolVersion_Then_ListResultsCarryNoCacheHints` / `Given_McpConcurrentSessions.When_ModernClientListsTools_Then_ListResultIsTaggedPrivateAndStale` |
+| Message notifications for a request that declared no log level | Emitted, subject to the session threshold | **Not emitted** — the feedback rides in the result instead | `Given_McpUserFeedback.When_RequestDeclaresNoLogLevel_Then_FeedbackRidesInTheToolResultInstead` |
+| Same, through `prompts/get` | Emitted | Rides in the prompt result after the payload, and in the surfaced error when the prompt fails | `Given_McpUserFeedback.When_APromptDeclaresNoLogLevel_Then_FeedbackRidesInThePromptResultInstead` / `...When_AFailingPromptDeclaresNoLogLevel_Then_FeedbackRidesInTheError` |
+
+## Tool list invariance on `2026-07-28`
+
+The tools chapter of that revision states:
+
+> This set **MAY** be empty and **MAY** change over time (see List Changed Notification), but
+> **MUST NOT** vary per-connection or as a side effect of other requests on the connection. The set
+> **MAY** vary by the authorization presented on the request — for example, returning only the tools
+> the caller's granted scopes permit — since credentials are per-request input, not connection state.
+
+The earlier revisions carry no such rule, which is why the behaviours above are split by era rather
+than changed outright.
+
+Two consequences, and they are different problems:
+
+- **Per-connection variance.** A module gated on `IMcpClientRoots.IsSupported` would advertise a
+ different set to a client that declares roots. On `2026-07-28` discovery answers every
+ per-connection question with a constant, so the set no longer depends on who asked.
+- **Variance as a side effect.** A module gated on `HasSoftRoots` would appear after a `tools/call`
+ set them — changing the caller's own advertised set. This one needs no second connection to be
+ observable, so it is the half that matters even on plain stdio. A module gated on session state is
+ the same shape and reaches further: `IReplSessionState` is mutable, shared with execution, and a
+ command can write it and call `InvalidateRouting()`. The compatibility bootstrap is that shape too —
+ an intro catalog followed by the real one — and is therefore legacy-only.
+
+The discovery view reaches no live session-scoped service at all, rather than neutralising member by
+member: `IsSupported`, `HasSoftRoots`, `Current` and `GetAsync` are all connection state, and
+forwarding any one of them reopens the hole. The frozen set is the four capability services plus
+`IReplSessionState`, `IReplSessionInfo` and `IReplInteractionChannel` — every session-scoped input a
+presence predicate can receive by injection. The channel is in the set because a predicate may *ask*:
+the live one answers from the call's own `answer.*` arguments, which would let a tool argument decide
+whether the tool it was passed to exists.
+
+A predicate that injects an **application** service of its own is outside that set by construction:
+Repl cannot know which of your singletons is stable and which a command mutates. Gate on something
+that does not change, or keep the command mapped unconditionally and fail inside it. Framework services
+stay Repl's responsibility: a predicate gating on a launch global — `--env prod mcp serve` — reads the
+same values, and the same `HasValue`, during a tool call as it did during discovery. A sub-invocation
+carries its own tokens but cannot retract what the session provided.
+
+What stays allowed is a set that **changes over time** for everyone: `InvalidateRouting()` is
+application-global, and the resulting `notifications/*/list_changed` reaches every connection with the
+same new graph.
+
+When a rebuild **fails**, the eras diverge for that same reason. An initialize-era session keeps
+serving its previous catalog until the failure clears — there the catalog is session state, and a set
+that differs per connection is the point. A `2026-07-28` request fails instead: answering it from its
+own cache is the per-connection variance above, because a connection that had not read the catalog
+since the last change would keep its older set while another already serves the newer one, and the
+failure would hold that difference in place for as long as it lasts. Failing is transient — the next
+request retries, without needing another `InvalidateRouting()`.
+
+### What this means when you write commands
+
+Module presence predicates still work, and still work on both eras. On `2026-07-28` discovery runs
+them against **fixed answers** instead of against the client:
+
+| Member | What discovery answers |
+| --- | --- |
+| `IsSupported` (roots, sampling, elicitation), `IsLoggingSupported`, `IsProgressSupported` | `true` |
+| `HasSoftRoots` | `false` |
+| `Current`, `GetAsync()` | empty |
+| A question asked through `IReplInteractionChannel` | its declared default — nothing is prefilled, and there is no client to elicit or sample from |
+
+Whatever your predicate returns under those answers is what **every** client is offered. The bucket a
+command lands in therefore follows the predicate's *result*, not which member it reads — a negated
+gate lands in the opposite bucket from the plain one. Two consequences worth stating in full:
+
+- A predicate that comes out **true** — `roots.IsSupported`, `sampling.IsSupported` — advertises its
+ command to every client. Repl guarantees such a command is **reachable**: execution decides presence
+ from the same fixed answers, so what was advertised can be called, and the handler runs with the
+ real client rather than the catalog's view of it. Writing the failure is then yours — return an
+ error naming the missing capability rather than relying on the command being absent. That is the
+ shape the specification prescribes: a tool execution error is "actionable feedback that language
+ models can use to self-correct".
+- A predicate that comes out **false** — `!roots.IsSupported`, `roots.HasSoftRoots`,
+ `roots.Current.Count > 0` — advertises its command to no client at all, and it disappears with no
+ error to explain it. Map those commands unconditionally instead.
+
+The soft-roots bootstrap pattern gates on `!roots.IsSupported`, so despite reading a capability it
+lands in the second group. That is the reason to read the rule off the predicate's result rather than
+off the member it consults.
+
+Execution is untouched either way. `SetSoftRoots` still works, and `IMcpClientRoots.Current` answers
+with the connection's real roots under `mcp serve`; on a reused `BuildMcpServerOptions()` result it
+answers empty until *this request* has called `GetAsync`, which is that path's documented contract.
+
+For state that must survive across calls, the specification's own answer is an explicit handle
+returned by a creation tool and passed back as an argument, rather than implicit connection state.
+
+## Deliberate gaps
+
+| Gap | Why | Tracked |
+| --- | --- | --- |
+| No per-caller command graph | The one variance `2026-07-28` permits is by the authorization presented on the request. Repl has no request-authorization concept yet, so it advertises one graph to everyone. | [#97](https://github.com/yllibed/repl/issues/97) |
+| `*/list_changed` is advertised on the reusable-options path but never fires there | The SDK forces the flag true for any non-null collection, and the pre-built catalog always supplies one. | [#94](https://github.com/yllibed/repl/issues/94) |
+| A multi-connection custom transport sees considerations this page does not solve | `mcp serve` is one connection per process; a host that multiplexes connections over one options instance owns the isolation questions that follow. See [Transports](mcp-transports.md). | — |
+
+## Extensions and SEPs
+
+| Identifier | Status in Repl |
+| --- | --- |
+| SEP-2549 — `cacheScope` / `ttlMs` | Set on list and resource results, on `2026-07-28` only |
+| SEP-2575 — stateless requests: per-request `_meta`, and no message notification without a declared log level | The protocol version in `_meta` is what selects the era on every request; the log-level rule is honoured, and the feedback is appended to the result instead |
+| SEP-2577 — Roots, Sampling and Logging deprecated | Still supported for the compatibility path; the SDK reports them under diagnostic `MCP9005` |
+| SEP-2567 — protocol sessions removed; list endpoints made session-independent | The source of the invariance rule above; cross-call state becomes an explicit handle passed as a tool argument, not connection state |
+| Tasks (`io.modelcontextprotocol/tasks`) | Not advertised; the SDK moved it out of the core package |
diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md
index 9f3f42a3..ac42537d 100644
--- a/docs/mcp-overview.md
+++ b/docs/mcp-overview.md
@@ -64,7 +64,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld();
| `.Destructive()` | Ask user for confirmation, sequential |
| `.Idempotent()` | Safe to retry, can parallelize |
| `.OpenWorld()` | Reaches external systems — expect latency and transient failures |
-| `.LongRunning()` | Enables call-now/poll-later pattern |
+| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns once Repl integrates the SDK Tasks extension — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) |
| `.AutomationHidden()` | Not visible to agents |
**Annotate every command exposed to agents.** Unannotated tools force agents to assume the worst: confirm everything, no parallelism, no retries.
diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md
index 962da647..3c556330 100644
--- a/docs/mcp-reference.md
+++ b/docs/mcp-reference.md
@@ -4,7 +4,7 @@
>
> **Purpose:** Complete reference for MCP server features. Consult, don't read end-to-end.
> **Prerequisite:** [MCP overview](mcp-overview.md)
-> **Related:** [Advanced patterns](mcp-advanced.md) · [Sampling & elicitation](mcp-agent-capabilities.md) · [Transports](mcp-transports.md)
+> **Related:** [Advanced patterns](mcp-advanced.md) · [Sampling & elicitation](mcp-agent-capabilities.md) · [Transports](mcp-transports.md) · [Conformance](mcp-conformance.md)
## Rich descriptions
@@ -289,6 +289,13 @@ The interaction channel is the preferred API when the feedback should stay porta
| `WriteWarningAsync(...)` | warning-level message notification |
| `WriteProblemAsync(...)` | error-level message notification |
+Those notification rows describe an initialize-era session. On `2026-07-28` a request that declared no
+`_meta/io.modelcontextprotocol/logLevel` must not receive message notifications at all, so the same
+calls are appended to the tool result instead — see
+[SDK and protocol versions](#sdk-and-protocol-versions). A resource read that _succeeds_ has nowhere
+to put them — its body must match the advertised MIME type — so it drops them; a read that fails
+carries them in the surfaced error, which is the only place left for them.
+
Notes:
- `ClearProgressAsync()` clears local host rendering. MCP clients typically just stop receiving progress updates and then see the final tool result.
@@ -512,6 +519,94 @@ side-channel command output and are not included in `resources/read` bodies.
Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data.
+### SDK and protocol versions
+
+- Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently at **2.2.0**. The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts.
+- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) shipped experimentally in the SDK 2.0 preview line and is stable as of 2.2.0; Repl has not adopted it yet.
+- **Discovery notifications** follow the negotiated revision. Repl drives the SDK's own fan-out rather than broadcasting itself, so an initialize-era client keeps receiving unsolicited `*/list_changed` while a `2026-07-28` client receives only the notification types it requested through `subscriptions/listen`, each tagged with its listen request id (SEP-2575). A modern client that opens no subscription receives none — which is what the specification requires. List results carry `ttlMs: 0`, so such a client re-lists on demand rather than caching.
+- **User feedback** (`notice` / `warning` / `problem`, and `IMcpFeedback.SendMessageAsync`) follows the same split. On `2026-07-28`, `logging/setLevel` is gone and a server must not emit `notifications/message` for a request that declared no `_meta/io.modelcontextprotocol/logLevel`. Messages that cannot be delivered as notifications are appended to the **tool result** instead, after the command's own payload, so no host loses them. Appending is additive: an explicitly registered prompt that returns its own `GetPromptResult` keeps the description and `_meta` it set, alongside the appended notices. The exception is a resource read that succeeds: its result is a typed body, so buffered feedback is dropped rather than appended — a read that _fails_ carries it in the surfaced error. Initialize-era clients keep the session-wide `logging/setLevel` behaviour unchanged. Note that the SDK's own client cannot request a level on `2026-07-28` at all, so in practice modern hosts see feedback in the tool result.
+- **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72).
+
+### Upgrading from the 1.x SDK
+
+Eight things change for an application that already references `Repl.Mcp`. The first two are build
+breaks; the rest are behaviour a consumer meets at runtime.
+
+**The SDK moves to 2.x.** `ModelContextProtocol` is a transitively public dependency, so a consumer
+that also references it directly has to move with this package. There is no compatibility shim: the
+1.x and 2.x assemblies cannot coexist in one dependency graph.
+
+**`IMcpFeedback.SendMessageAsync` takes `McpMessageLevel`** instead of the SDK's `LoggingLevel`. The
+members and their numeric values are identical, so the swap is mechanical. It is not cosmetic,
+though: `LoggingLevel` carries the SDK's `MCP9005` deprecation, and a `#pragma` inside Repl never
+covered a _consumer's_ compilation — anyone building with warnings as errors got a hard error on a
+Repl signature.
+
+**Tool results can carry more content blocks than before.** A message a command reported that the
+client could not receive as a notification is appended to the tool result. The command's own payload
+stays the first block and `StructuredContent` is untouched, so a caller reading either is unaffected
+— but a test asserting the result has exactly one content block will now fail. See the **User feedback**
+bullet under [SDK and protocol versions](#sdk-and-protocol-versions) for when this happens.
+
+**Module presence no longer varies with the client on `2026-07-28`.** That revision requires the
+advertised set not to vary per-connection, nor to change as a side effect of another request on the
+connection, so discovery there runs every presence predicate against fixed answers: `IsSupported`,
+`IsLoggingSupported` and `IsProgressSupported` are true, `HasSoftRoots` is false, `Current` and
+`GetAsync()` are empty. The predicate still runs normally on the earlier revisions and outside MCP.
+
+Whatever the predicate returns under those answers is what every client is offered, so read the rule
+off the **result**, not off the member:
+
+- Comes out true (`roots.IsSupported`, `sampling.IsSupported`): advertised to **every** client, and
+ the command now has to fail with a clear error when the capability is in fact missing rather than
+ rely on being absent.
+- Comes out false (`!roots.IsSupported`, `roots.HasSoftRoots`, `roots.Current.Count > 0`): advertised
+ to **no** client, disappearing with no error to explain it. Map those commands unconditionally. The
+ soft-roots bootstrap gates on `!roots.IsSupported` and falls here despite reading a capability.
+
+On a reused `BuildMcpServerOptions()` result this applies to **every** client, including an
+initialize-era one: that catalog is built once, before any request names an era, so it is built with
+the modern view and served as-is to whoever connects. The per-era behaviour above is what `mcp serve`
+gives you, where the catalog is built per connection.
+
+Execution is untouched: `SetSoftRoots` still works, and `IMcpClientRoots.Current` answers with the
+connection's real roots under `mcp serve` — on a reused `BuildMcpServerOptions()` result it answers
+empty until this request has called `GetAsync`, as the bullet below already states. See
+[Conformance](mcp-conformance.md#tool-list-invariance-on-2026-07-28).
+
+**`.LongRunning()` no longer advertises task support on the protocol surface**, because SDK 2.x
+removed the per-tool execution augmentation. The annotation still carries into help and documentation;
+protocol-level task support returns with issue #72.
+
+**`IsLoggingSupported` is `false` for every SDK-client request on `2026-07-28`.** A command that
+guards expensive work on it will now skip that work against a modern host. Messages sent anyway ride
+back in the tool result, so the usual fix is to stop guarding — except during a resource read, where
+there is nowhere to put them and they are dropped.
+
+**Native roots are resolved per request on a reused `BuildMcpServerOptions()` result.** Previously one
+connection's `roots/list` answer was cached for the life of the options instance and handed to every
+other connection; it is now fetched at most once per request and forgotten with it. Two consequences
+for a command on that hosting path: `GetAsync` costs a round-trip per request rather than one in
+total, and `Current` answers empty until _this_ request has called `GetAsync`.
+
+Under `mcp serve` the cost is unchanged — one `roots/list` per connection — but `Current` now falls
+back to soft roots while nothing native has been resolved, whether because it has not been asked yet
+or because the client could not be reached. An empty answer therefore means the roots in force are
+empty, not that resolving them failed; a client that genuinely answers with zero roots is still told
+apart, since that answer counts as resolved. Call `GetAsync` when the difference matters: it resolves
+on demand and surfaces the failure instead of absorbing it.
+
+**An uncaught exception no longer reaches the client as text.** A command that throws, or an
+application callback that fails while supplying a parameter — a service factory, an options-group
+constructor, a property setter — is surfaced to an MCP client as `Command failed with exit code N.`
+The framework renders that message for an operator at a console, and it routinely carries a path, a
+parameter and its CLR type, or a connection string; over MCP the reader is a remote client instead.
+Feedback the application itself reported still travels, because the application wrote it for that
+reader — so return an error from the command when the client needs to know why. Nothing changes
+locally: the console still names the cause. One detail for a host reading outcomes directly, such as
+an `ExitCodes.Resolver` — a binding-callback failure now carries `ReplBindingCallbackException` on
+`ReplExecutionOutcome.Exception`, with the application's own exception in `InnerException`.
+
| Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue |
|---|---|---|---|---|---|---|
| Tools | Yes | Yes | Yes | Yes | Yes | Yes |
diff --git a/docs/mcp-transports.md b/docs/mcp-transports.md
index e8d71c4d..08334cdf 100644
--- a/docs/mcp-transports.md
+++ b/docs/mcp-transports.md
@@ -46,6 +46,35 @@ async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken
}
```
+Client capabilities (sampling, elicitation, roots) resolve per **request** on this path, so each
+connection sees its own — that is a property of the request, not of the options instance.
+
+> **Known limitation:** cross-call state does not resolve per request, and three consequences are
+> worth knowing before you choose this shape. The `2026-07-28` revision removed protocol-level
+> sessions, so this path has no per-connection identity to hang state on.
+>
+> [Soft roots](mcp-advanced.md#soft-roots-fallback) set by one connection are visible to every other
+> connection built from the same options — they are host-set state with no request to belong to. If
+> your commands rely on them, host one server per process (`mcp serve`) or pass the workspace as an
+> explicit command argument. Note that on `2026-07-28` soft roots never reveal commands on any
+> transport: the advertised set must not change as a side effect of a `tools/call`. See
+> [Conformance](mcp-conformance.md#tool-list-invariance-on-2026-07-28).
+>
+> The command catalog is frozen when `BuildMcpServerOptions()` returns. A server built from it never
+> emits `*/list_changed`, even though the SDK advertises the capability, so a client that would
+> refresh on that notification never does. Commands whose visibility changes at runtime — dynamic
+> tools — need `mcp serve`. Presence predicates are a separate matter on `2026-07-28`: discovery
+> resolves every per-connection question to a constant there, so the predicate's value under those
+> constants decides presence once and for all. One that evaluates true is advertised to every client;
+> one that evaluates false — a data gate, or a negated capability gate such as `!roots.IsSupported` —
+> is advertised to none, and a frozen catalog has no later chance to change its mind. See
+> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands).
+>
+> Native roots are safe here: they are resolved per request rather than cached per connection, so one
+> client never sees another's workspace. The cost is one `roots/list` round-trip per request that asks
+> for them. Under stateless HTTP the SDK reports no client capabilities at all, so native roots are
+> unavailable on that transport whatever you do.
+
## Scenario B: MCP-over-HTTP
The MCP spec also defines an HTTP transport. For that, you typically host MCP inside ASP.NET Core rather than through `mcp serve`.
@@ -64,14 +93,19 @@ var mcpOptions = app.Core.BuildMcpServerOptions(configure: o =>
You can then pass those options to the MCP SDK's HTTP integration.
-## Session isolation
+## What is isolated, and at which boundary
-Each connection or HTTP session is isolated:
+The `2026-07-28` revision removed protocol-level sessions: the client declares its capabilities on
+every request rather than once per connection. So the boundaries are not all the same size.
-- its own MCP session
-- its own I/O capture
-- its own session-aware routing state
+| Isolated per | What |
+|---|---|
+| Request | Client capabilities, the requested log level, the destination for sampling, elicitation and progress, and — on a reused `BuildMcpServerOptions()` result — native roots, resolved once per request and never cached across connections |
+| Invocation | I/O capture — each tool call gets its own capture scope, not one per connection |
+| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, and session-aware routing state |
-That matters especially when using dynamic tools, roots, or session-specific modules.
+A server created from a reused `BuildMcpServerOptions()` result has everything above except the
+connection row; see the known limitation above. That matters especially when using dynamic tools,
+roots, or session-specific modules.
For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md).
diff --git a/docs/module-presence.md b/docs/module-presence.md
index aa51ec34..338cdc20 100644
--- a/docs/module-presence.md
+++ b/docs/module-presence.md
@@ -2,6 +2,18 @@
This page explains how to make modules appear/disappear dynamically at runtime.
+> **Serving MCP?** On revision `2026-07-28` the advertised tool set must not vary per connection or
+> change as a side effect of another request, so discovery answers every session-scoped question with
+> fixed answers: capability checks read as supported, soft roots as absent, the root list as empty,
+> **and the session state as empty**. Whatever your predicate returns under those answers is what
+> every client is offered, so a negated gate such as `!roots.IsSupported` matches for nobody even
+> though it reads a capability — and the sign-in flow below reveals nothing, because the state it
+> writes is not what discovery reads. Gate on something that does not change, or map the command
+> unconditionally and refuse inside it. A command that *is* advertised stays callable, so refusing
+> inside it is what the caller can act on. The predicate still runs everywhere else, and the earlier
+> revisions are unaffected — see
+> [Conformance](mcp-conformance.md#what-this-means-when-you-write-commands).
+
## Why
Sometimes the command surface depends on session state:
@@ -82,6 +94,11 @@ Example flow:
3. App invalidates routing cache.
4. Signed-in module becomes present on next command resolution.
+This flow works in the console, over the earlier MCP revisions, and anywhere else. It does **not**
+change what an MCP client on `2026-07-28` is offered: that revision forbids the advertised set from
+moving as a side effect of another request, which is exactly what step 2 would be. See the note at the
+top of this page.
+
## Conflict policy
If two **active** modules map the same route, **last registration wins**.
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 8a0a7bcc..b8d7f6db 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -14,7 +14,7 @@
-
+
diff --git a/src/Repl.Core/CommandAnnotations.cs b/src/Repl.Core/CommandAnnotations.cs
index 9b7a5dbf..61c54d23 100644
--- a/src/Repl.Core/CommandAnnotations.cs
+++ b/src/Repl.Core/CommandAnnotations.cs
@@ -31,8 +31,9 @@ public sealed record CommandAnnotations
public bool OpenWorld { get; init; }
///
- /// Indicates the command may take a long time to complete.
- /// Enables task-based execution in programmatic clients.
+ /// Indicates the command may take a long time to complete, so programmatic clients
+ /// should expect a slow call. Protocol-level task-based execution (MCP Tasks) is not
+ /// advertised until Repl integrates the SDK's Tasks extension (issue #72).
///
public bool LongRunning { get; init; }
diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs
index ce67001e..dfcd7bf8 100644
--- a/src/Repl.Core/CoreReplApp.Execution.cs
+++ b/src/Repl.Core/CoreReplApp.Execution.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
@@ -53,11 +53,25 @@ internal ValueTask RunSubInvocationAsync(
CancellationToken cancellationToken = default) =>
ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken);
- ValueTask ISubInvocableReplApp.RunSubInvocationAsync(
+ async ValueTask ISubInvocableReplApp.RunSubInvocationWithOutcomeAsync(
string[] args,
IServiceProvider serviceProvider,
- CancellationToken cancellationToken) =>
- RunSubInvocationAsync(args, serviceProvider, cancellationToken);
+ IServiceProvider? presenceServiceProvider,
+ CancellationToken cancellationToken)
+ {
+ var outcome = await RunUnderCancellationPolicyAsync(
+ args,
+ serviceProvider,
+ isSubInvocation: true,
+ cancellationToken,
+ presenceServiceProvider)
+ .ConfigureAwait(false);
+
+ return new SubInvocationOutcome(
+ ResolveProcessExitCode(outcome, isSubInvocation: true),
+ outcome.Kind,
+ outcome.Exception);
+ }
private async ValueTask ExecuteCoreAsync(
IReadOnlyList args,
@@ -107,11 +121,39 @@ internal ValueTask RunOutcomeWithServicesAsync(
return ExecutionOutcome.Cancelled(new OperationCanceledException(cancellationToken));
}
+ ///
+ /// What a failure says to the operator running this process.
+ ///
+ ///
+ /// is a wrapper whose own message names only what could
+ /// not be supplied; the cause is inside it. Marking the failure exists so a host publishing to
+ /// somebody other than the operator can withhold that cause — it decides by the exception's type, not
+ /// by this text — and here the reader is the operator, who came for exactly that cause.
+ ///
+ private static string DescribeLocally(Exception exception)
+ {
+ var cause = exception is ReplBindingCallbackException { InnerException: { } marked }
+ ? marked
+ : exception;
+
+ // Reflection adds its own layer on top of what the application threw — a property setter, an
+ // options-group constructor and a keyed-service factory all reach the binder through it — and
+ // "Exception has been thrown by the target of an invocation" is not the diagnostic the operator
+ // came for. Unwrap until the application's own failure is what remains.
+ while (cause is System.Reflection.TargetInvocationException { InnerException: { } deeper })
+ {
+ cause = deeper;
+ }
+
+ return cause.Message;
+ }
+
private async ValueTask RunUnderCancellationPolicyAsync(
IReadOnlyList args,
IServiceProvider serviceProvider,
bool isSubInvocation,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ IServiceProvider? presenceServiceProvider = null)
{
_options.Interaction.SetObserver(observer: ExecutionObserver);
try
@@ -120,7 +162,12 @@ private async ValueTask RunUnderCancellationPolicyAsync(
{
// Inside the try so a token cancelled before the run follows the same Cancelled policy.
cancellationToken.ThrowIfCancellationRequested();
- return await ExecuteCoreOutcomeAsync(args, serviceProvider, isSubInvocation, cancellationToken)
+ return await ExecuteCoreOutcomeAsync(
+ args,
+ serviceProvider,
+ isSubInvocation,
+ cancellationToken,
+ presenceServiceProvider)
.ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (IsConvertibleCancellation(isSubInvocation, cancellationToken))
@@ -156,7 +203,8 @@ private async ValueTask ExecuteCoreOutcomeAsync(
IReadOnlyList args,
IServiceProvider serviceProvider,
bool isSubInvocation,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ IServiceProvider? presenceServiceProvider = null)
{
if (ReplSessionIO.IsProgrammatic && !ReplSessionIO.HasCurrentProgrammaticInvocationContract)
{
@@ -179,7 +227,12 @@ private async ValueTask ExecuteCoreOutcomeAsync(
return globalDiagnostics;
}
- return await ExecuteParsedCoreAsync(globalOptions, serviceProvider, isSubInvocation, cancellationToken)
+ return await ExecuteParsedCoreAsync(
+ globalOptions,
+ serviceProvider,
+ isSubInvocation,
+ cancellationToken,
+ presenceServiceProvider)
.ConfigureAwait(false);
}
@@ -273,14 +326,18 @@ private async ValueTask ExecuteParsedCoreAsync(
GlobalInvocationOptions globalOptions,
IServiceProvider serviceProvider,
bool isSubInvocation,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ IServiceProvider? presenceServiceProvider = null)
{
- _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations
+ _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions, preserveSessionExplicitKeys: isSubInvocation); // volatile ref swap — safe under concurrent sub-invocations
if (!isSubInvocation)
{
_globalOptionsSnapshot.SetSessionBaseline();
}
- using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false);
+ using var runtimeStateScope = PushRuntimeState(
+ serviceProvider,
+ isInteractiveSession: false,
+ presenceServiceProvider);
var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens);
var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens };
var ambiguousOutcome = await TryHandleAmbiguousPrefixAsync(
@@ -838,7 +895,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma
// keys on `bound`, so a service factory that cancels before binding completes is a
// BindingError. The interactive loop keeps its own Ctrl+C semantics.
return (await RenderFailureAsync(
- Results.Error("execution_error", ex.Message), ex, bound, globalOptions, serviceProvider, cancellationToken)
+ Results.Error("execution_error", DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken)
.ConfigureAwait(false), false);
}
catch (OperationCanceledException)
@@ -846,10 +903,21 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma
await TryClearProgressAsync(serviceProvider).ConfigureAwait(false);
throw;
}
+ // Ahead of the InvalidOperationException arm below, which the marker derives from. A validation
+ // result says the caller's input was wrong; this one says application code threw while supplying
+ // a parameter, and the caller has no way to act on it. Classified by WHO failed rather than by
+ // what type they threw, so a factory, an options-group constructor and a property setter answer
+ // alike — the binder's own diagnostics about bad input keep the arm below to themselves.
+ catch (ReplBindingCallbackException ex)
+ {
+ return (await RenderFailureAsync(
+ Results.Error("execution_error", DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken)
+ .ConfigureAwait(false), false);
+ }
catch (InvalidOperationException ex)
{
return (await RenderFailureAsync(
- Results.Validation(ex.Message), ex, bound, globalOptions, serviceProvider, cancellationToken)
+ Results.Validation(DescribeLocally(ex)), ex, bound, globalOptions, serviceProvider, cancellationToken)
.ConfigureAwait(false), false);
}
catch (Exception ex)
diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs
index e998ac74..e6916f9d 100644
--- a/src/Repl.Core/CoreReplApp.cs
+++ b/src/Repl.Core/CoreReplApp.cs
@@ -608,7 +608,8 @@ private ReplRuntimeChannel ResolveCurrentRuntimeChannel()
internal ActiveRoutingGraph ResolveActiveRoutingGraph(bool useDurableCache)
{
var runtime = _runtimeState.Value;
- var serviceProvider = runtime?.ServiceProvider ?? _services;
+ // Presence decides the graph, so it is what the cache is keyed on and what the predicates see.
+ var serviceProvider = runtime?.PresenceServiceProvider ?? runtime?.ServiceProvider ?? _services;
var channel = ResolveCurrentRuntimeChannel();
var cacheVersion = Interlocked.Read(ref _routingCacheVersion);
var cacheBucket = _routingCacheByServiceProvider.GetOrCreateValue(serviceProvider);
@@ -666,6 +667,33 @@ private HashSet ResolveActiveModuleIds(ModulePresenceContext context)
return active;
}
+ ///
+ /// Whether any route was ever registered that satisfies , whatever its
+ /// module's presence predicate would decide and whether or not a later registration shadows it.
+ ///
+ ///
+ /// For a caller that must answer what the application can contain rather than what one
+ /// resolution does contain — declaring an optional protocol capability, for instance, which happens
+ /// once and cannot be revised per caller. Blind to presence, because an answer derived from one
+ /// evaluation of the predicates is only as good as that evaluation's inputs and a predicate can
+ /// depend on state the caller does not have; blind to shadowing, because a template registered
+ /// twice resolves to a single route while the shadowed registration is still reachable from any
+ /// resolution that excludes the shadowing module. Yields a verdict rather than the routes so that
+ /// no caller can project command names through it, and resolves, documents and validates nothing.
+ ///
+ internal bool AnyRegisteredRoute(Func predicate)
+ {
+ foreach (var route in _routes)
+ {
+ if (predicate(route))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private RouteDefinition[] ResolveActiveRoutes(HashSet activeModuleIds)
{
var routesByPath = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -715,10 +743,16 @@ private ContextDefinition[] ResolveActiveContexts(HashSet activeModuleIds)
///
internal bool IsInteractiveSession => _runtimeState.Value?.IsInteractiveSession == true;
- internal RuntimeStateScope PushRuntimeState(IServiceProvider serviceProvider, bool isInteractiveSession)
+ internal RuntimeStateScope PushRuntimeState(
+ IServiceProvider serviceProvider,
+ bool isInteractiveSession,
+ IServiceProvider? presenceServiceProvider = null)
{
var previous = _runtimeState.Value;
- _runtimeState.Value = new InvocationRuntimeState(serviceProvider, isInteractiveSession);
+ _runtimeState.Value = new InvocationRuntimeState(
+ serviceProvider,
+ isInteractiveSession,
+ presenceServiceProvider);
return new RuntimeStateScope(_runtimeState, previous);
}
@@ -880,9 +914,19 @@ private readonly record struct ModuleRegistration(
int ModuleId,
Func IsPresent);
+ /// Resolves handler arguments for this invocation.
+ /// Whether the invocation belongs to an interactive session.
+ ///
+ /// Resolves module presence predicates, when they must be decided from something other than what
+ /// binds the handler. A host that publishes a command catalog has to keep the two apart: what it
+ /// advertised was decided from one view of the world, and re-deciding at execution from another
+ /// makes an advertised command unreachable. — the default everywhere except
+ /// that case — means presence and binding share one provider, as they always have.
+ ///
internal readonly record struct InvocationRuntimeState(
IServiceProvider ServiceProvider,
- bool IsInteractiveSession);
+ bool IsInteractiveSession,
+ IServiceProvider? PresenceServiceProvider = null);
private sealed class RoutingCacheEntry(long version, ActiveRoutingGraph graph)
{
diff --git a/src/Repl.Core/ISubInvocableReplApp.cs b/src/Repl.Core/ISubInvocableReplApp.cs
index 6846e1ca..2057c926 100644
--- a/src/Repl.Core/ISubInvocableReplApp.cs
+++ b/src/Repl.Core/ISubInvocableReplApp.cs
@@ -1,9 +1,20 @@
-namespace Repl;
+namespace Repl;
internal interface ISubInvocableReplApp
{
- ValueTask RunSubInvocationAsync(
+ ///
+ /// Runs a nested invocation against the host's own command graph, and reports how it ended.
+ ///
+ /// Command-line tokens for the sub-invocation.
+ /// Resolves handler arguments.
+ ///
+ /// Decides module presence, when that must not be decided from —
+ /// a host that already published a catalog has to run the command the catalog promised.
+ ///
+ /// Cancels the run.
+ ValueTask RunSubInvocationWithOutcomeAsync(
string[] args,
IServiceProvider serviceProvider,
+ IServiceProvider? presenceServiceProvider = null,
CancellationToken cancellationToken = default);
}
diff --git a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs
index 7884809a..ea554a92 100644
--- a/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs
+++ b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs
@@ -1,4 +1,4 @@
-namespace Repl;
+namespace Repl;
internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor
{
@@ -27,10 +27,35 @@ internal void SetSessionBaseline()
_currentValues = baseline;
}
- internal void Update(IReadOnlyDictionary> parsedValues)
+ /// The globals this invocation carried on its own tokens.
+ ///
+ /// Whether the session's own globals count as explicitly provided. A sub-invocation carries only
+ /// its own tokens, but the session's values stay in effect — they are merged in below.
+ /// Explicitness has to travel with them, or denies an option whose value
+ /// still returns, and a module presence predicate reading it decides
+ /// differently depending on which invocation ran last. A top-level run passes
+ /// : it is about to become the baseline itself, and carrying the previous
+ /// one's keys into is the leak that method exists to prevent.
+ /// The interactive resolver is the third caller and also passes : each
+ /// committed line is a fresh invocation, so a baseline-only key is in force without having been
+ /// provided on it — which is what reports.
+ ///
+ internal void Update(
+ IReadOnlyDictionary> parsedValues,
+ bool preserveSessionExplicitKeys = false)
{
- _explicitKeys = new HashSet(parsedValues.Keys, StringComparer.OrdinalIgnoreCase);
- var merged = new Dictionary>(_sessionBaseline, StringComparer.OrdinalIgnoreCase);
+ var baseline = _sessionBaseline;
+ var explicitKeys = new HashSet(parsedValues.Keys, StringComparer.OrdinalIgnoreCase);
+ if (preserveSessionExplicitKeys)
+ {
+ foreach (var key in baseline.Keys)
+ {
+ explicitKeys.Add(key);
+ }
+ }
+
+ _explicitKeys = explicitKeys;
+ var merged = new Dictionary>(baseline, StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in parsedValues)
{
merged[key] = value;
diff --git a/src/Repl.Core/Parsing/HandlerArgumentBinder.cs b/src/Repl.Core/Parsing/HandlerArgumentBinder.cs
index 91d6e7c5..07590a0a 100644
--- a/src/Repl.Core/Parsing/HandlerArgumentBinder.cs
+++ b/src/Repl.Core/Parsing/HandlerArgumentBinder.cs
@@ -55,7 +55,11 @@ internal static class HandlerArgumentBinder
if (context.ImplicitServiceParameters.TryGetGlobalOptionsServiceType(parameter.ParameterType, out var globalOptionsServiceType))
{
- var globalOptions = context.ServiceProvider.GetService(globalOptionsServiceType);
+ var globalOptions = Activate(
+ context.ServiceProvider,
+ globalOptionsServiceType,
+ parameter.Name ?? "?",
+ context.CancellationToken);
if (globalOptions is not null)
{
return globalOptions;
@@ -213,7 +217,12 @@ private static bool TryResolveFromContextOrServices(
if (hasFromServices)
{
- return ResolveExplicitFromServices(parameter, context.ServiceProvider, fromServices!, out resolved);
+ return ResolveExplicitFromServices(
+ parameter,
+ context.ServiceProvider,
+ fromServices!,
+ context.CancellationToken,
+ out resolved);
}
return ResolveImplicitFromContextOrServices(parameter, context, skipContext, out resolved);
@@ -249,9 +258,10 @@ private static bool ResolveExplicitFromServices(
System.Reflection.ParameterInfo parameter,
IServiceProvider serviceProvider,
FromServicesAttribute fromServices,
+ CancellationToken cancellationToken,
out object? resolved)
{
- resolved = ResolveService(parameter.ParameterType, serviceProvider, fromServices.Key);
+ resolved = ResolveService(parameter.ParameterType, serviceProvider, fromServices.Key, cancellationToken);
if (resolved is not null)
{
return true;
@@ -278,7 +288,11 @@ private static bool ResolveImplicitFromContextOrServices(
object? contextValue = null;
var foundContext = !skipContext
&& TryResolveFromContext(parameter.ParameterType, context.ContextValues, out contextValue);
- var serviceValue = context.ServiceProvider.GetService(parameter.ParameterType);
+ var serviceValue = Activate(
+ context.ServiceProvider,
+ parameter.ParameterType,
+ parameter.Name ?? "?",
+ context.CancellationToken);
if (foundContext && serviceValue is not null)
{
throw new InvalidOperationException(
@@ -320,16 +334,107 @@ private static bool TryResolveAllFromContext(
return true;
}
- private static object? ResolveService(Type parameterType, IServiceProvider serviceProvider, string? key)
+ private static object? ResolveService(
+ Type parameterType,
+ IServiceProvider serviceProvider,
+ string? key,
+ CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key))
{
- return serviceProvider.GetService(parameterType);
+ return Activate(serviceProvider, parameterType, parameterType.Name, cancellationToken);
+ }
+
+ try
+ {
+ return TryGetKeyedService(serviceProvider, parameterType, key);
+ }
+ catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken))
+ {
+ throw new ReplBindingCallbackException(parameterType.Name, exception);
+ }
+ }
+
+ /// Constructs an option group, marking anything its constructor raises.
+ private static object CreateGroupInstance(
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
+ Type groupType,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return Activator.CreateInstance(groupType)!;
+ }
+ catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken))
+ {
+ throw new ReplBindingCallbackException(groupType.Name, exception);
}
+ }
+
+ ///
+ /// Assigns one option-group property, marking anything its setter raises.
+ ///
+ ///
+ /// A setter is application code as much as a service factory is, and reaches the pipeline as the
+ /// same unmarked binding failure. It can expose the same paths and application state.
+ ///
+ private static void AssignProperty(
+ PropertyInfo property,
+ object instance,
+ object? value,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ property.SetValue(instance, value);
+ }
+ catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken))
+ {
+ throw new ReplBindingCallbackException(property.Name, exception);
+ }
+ }
- return TryGetKeyedService(serviceProvider, parameterType, key);
+ ///
+ /// Resolves a service, reporting what the container raises as application code failing rather than
+ /// letting it pass for a diagnostic the binder wrote itself.
+ ///
+ ///
+ /// The two are indistinguishable once they reach the pipeline — same outcome kind, commonly the same
+ /// exception type — and they deserve opposite treatment: the binder's own message explains what the
+ /// caller got wrong, while a factory's can name a path or a connection string. Marking the failure
+ /// here is what lets a host publishing to a remote caller keep one and withhold the other. Every
+ /// call the binder makes into application code is marked the same way; a service factory was simply
+ /// the first one found.
+ ///
+ private static object? Activate(
+ IServiceProvider serviceProvider,
+ Type serviceType,
+ string parameterName,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return serviceProvider.GetService(serviceType);
+ }
+ catch (Exception exception) when (IsApplicationFailure(exception, cancellationToken))
+ {
+ throw new ReplBindingCallbackException(parameterName, exception);
+ }
}
+ ///
+ /// Whether is application code failing rather than the caller
+ /// withdrawing.
+ ///
+ ///
+ /// Cancellation is told apart by who asked for it, not by the exception's type. A factory that runs
+ /// its own budget and gives up has failed like any other, and its message deserves the same
+ /// treatment; only the caller abandoning the run is a withdrawal, and that one is not ours to
+ /// relabel.
+ ///
+ private static bool IsApplicationFailure(Exception exception, CancellationToken cancellationToken) =>
+ exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested;
+
[UnconditionalSuppressMessage(
"Trimming",
"IL2075",
@@ -602,7 +707,7 @@ private static object BindOptionsGroup(
InvocationBindingContext context,
ref int positionalIndex)
{
- var instance = Activator.CreateInstance(groupType)!;
+ var instance = CreateGroupInstance(groupType, context.CancellationToken);
foreach (var property in groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
@@ -631,7 +736,7 @@ private static object BindOptionsGroup(
property.PropertyType,
context.NumericFormatProvider,
enumIgnoreCase);
- property.SetValue(instance, converted);
+ AssignProperty(property, instance, converted, context.CancellationToken);
continue;
}
@@ -649,7 +754,7 @@ private static object BindOptionsGroup(
// Same positional upper-bound parity as the handler-parameter path.
ThrowIfExplicitUpperBoundExceeded(
context.OptionSchema, propertyName, positionalIndex - positionalStart);
- property.SetValue(instance, positionalValue);
+ AssignProperty(property, instance, positionalValue, context.CancellationToken);
continue;
}
}
diff --git a/src/Repl.Core/ReplBindingCallbackException.cs b/src/Repl.Core/ReplBindingCallbackException.cs
new file mode 100644
index 00000000..fff0e5c5
--- /dev/null
+++ b/src/Repl.Core/ReplBindingCallbackException.cs
@@ -0,0 +1,24 @@
+namespace Repl;
+
+///
+/// Thrown when application code the binder invoked while supplying a handler argument raised. The
+/// original failure is the inner exception.
+///
+///
+/// This exists to tell two binding failures apart that are otherwise identical: a diagnostic the
+/// binder wrote for the caller ("cannot convert 'abc' to an int") and an exception that escaped
+/// application code — a service factory, an options-group constructor, a property setter. Both surface
+/// as a binding failure and both are commonly an , so neither
+/// the outcome kind nor the exception type distinguishes them — yet the first is meant to be read and
+/// the second can name a filesystem path, a connection string, or application state. A host that
+/// publishes failures to someone other than the operator uses this type to withhold the second while
+/// keeping the first; a local console keeps the inner cause, where the reader is the operator.
+///
+/// What the binder was supplying — a parameter or a property.
+/// Failure raised by the application code.
+public sealed class ReplBindingCallbackException(string target, Exception innerException)
+ : InvalidOperationException($"Supplying '{target}' failed.", innerException)
+{
+ /// Gets what the binder was supplying when the application code raised.
+ public string Target { get; } = target;
+}
diff --git a/src/Repl.Core/Session/ReplSessionIO.cs b/src/Repl.Core/Session/ReplSessionIO.cs
index b8a874bd..d122851b 100644
--- a/src/Repl.Core/Session/ReplSessionIO.cs
+++ b/src/Repl.Core/Session/ReplSessionIO.cs
@@ -1,4 +1,4 @@
-using System.Collections.Concurrent;
+using System.Collections.Concurrent;
namespace Repl;
@@ -262,6 +262,13 @@ public static TerminalCapabilities TerminalCapabilities
/// Activates a hosted session on the current async context.
/// Dispose the returned scope to deactivate.
///
+ ///
+ /// removeSessionOnDispose says whether disposing the scope also unregisters the session.
+ /// Left unset, ownership is inferred: a caller supplying its own sessionId is taken to own
+ /// the lifetime and unregister it itself, which is what a transport host does at shutdown. State
+ /// it instead when the identifier is a throwaway minted for one invocation — inference cannot tell
+ /// the two apart, and guessing wrong leaves an entry nothing will ever remove.
+ ///
public static IDisposable SetSession(
TextWriter output,
TextReader input,
@@ -269,7 +276,8 @@ public static IDisposable SetSession(
string? sessionId = null,
TextWriter? commandOutput = null,
TextWriter? error = null,
- bool isHostedSession = true)
+ bool isHostedSession = true,
+ bool? removeSessionOnDispose = null)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(input);
@@ -323,7 +331,7 @@ public static IDisposable SetSession(
previousIsProgrammatic,
previousProgrammaticInvocationContractVersion,
previousSessionId,
- removeSessionOnDispose: string.IsNullOrWhiteSpace(sessionId),
+ removeSessionOnDispose: removeSessionOnDispose ?? string.IsNullOrWhiteSpace(sessionId),
sessionIdToRemove: resolvedSessionId);
}
diff --git a/src/Repl.Core/SubInvocationOutcome.cs b/src/Repl.Core/SubInvocationOutcome.cs
new file mode 100644
index 00000000..4bcc56bd
--- /dev/null
+++ b/src/Repl.Core/SubInvocationOutcome.cs
@@ -0,0 +1,23 @@
+using System.Runtime.InteropServices;
+
+namespace Repl;
+
+///
+/// A sub-invocation's exit code together with how the run ended.
+///
+///
+/// A host that surfaces a failure to somebody other than the operator needs to know whether the text
+/// it is about to show was authored by the handler or rendered from an exception it never meant to
+/// report. The exit code alone cannot tell those apart.
+///
+/// Resolved process exit code.
+/// How the run ended.
+///
+/// Exception that ended the run, when one did. The kind says what happened; only the exception says
+/// where it came from, and a host deciding what a remote caller may read needs both.
+///
+[StructLayout(LayoutKind.Auto)]
+internal readonly record struct SubInvocationOutcome(
+ int ExitCode,
+ ReplExecutionOutcomeKind Kind,
+ Exception? Failure = null);
diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs
index b73f0848..f00ec1aa 100644
--- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs
+++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs
@@ -614,6 +614,34 @@ await sut.Core.RunSubInvocationAsync(
capturedTenant.Should().Be("acme");
}
+ [TestMethod]
+ [Description("Regression guard: a sub-invocation preserved the baseline VALUE but dropped its explicitness. Update merges parsed values over the session baseline, then replaced the explicit-key set with the sub-invocation's own keys — so HasValue denied an option whose value GetValue still returned. A module presence predicate reading HasValue then decided differently depending on whether a top-level run or a sub-invocation went last, which is how an MCP catalog advertises a tool its own execution rejects as unknown.")]
+ public async Task When_SubInvocationAfterRun_Then_BaselineGlobalOptionsAreStillExplicit()
+ {
+ bool? capturedHasTenant = null;
+ string? capturedTenant = null;
+ var sut = ReplApp.Create();
+ sut.UseGlobalOptions();
+ sut.Map("show", (TestGlobalOptions opts) => $"{opts.Tenant}");
+ sut.Map("check", (IGlobalOptionsAccessor globals) =>
+ {
+ capturedHasTenant = globals.HasValue("tenant");
+ capturedTenant = globals.GetValue("tenant");
+ return "ok";
+ });
+
+ // Top-level Run establishes the baseline with --tenant acme.
+ ConsoleCaptureHelper.Capture(
+ () => sut.Run(["show", "--tenant", "acme", "--no-logo"]));
+
+ await sut.Core.RunSubInvocationAsync(
+ ["--no-logo", "check"], sut.Services).ConfigureAwait(false);
+
+ capturedTenant.Should().Be("acme");
+ capturedHasTenant.Should().BeTrue(
+ because: "the value is still in effect, so denying it was provided contradicts GetValue");
+ }
+
[TestMethod]
[Description("Sub-invocation does not reset baseline for subsequent sub-invocations.")]
public async Task When_MultipleSubInvocations_Then_BaselineRemainsStable()
diff --git a/src/Repl.Mcp/IMcpClientRoots.cs b/src/Repl.Mcp/IMcpClientRoots.cs
index dcfc1a3a..16216b39 100644
--- a/src/Repl.Mcp/IMcpClientRoots.cs
+++ b/src/Repl.Mcp/IMcpClientRoots.cs
@@ -1,4 +1,4 @@
-namespace Repl.Mcp;
+namespace Repl.Mcp;
///
/// Provides access to MCP client roots for the current MCP session.
@@ -17,8 +17,23 @@ public interface IMcpClientRoots
///
/// Gets the current effective roots for the session.
- /// Native roots are preferred when supported; otherwise soft roots are returned.
+ /// Native roots are preferred once resolved; otherwise soft roots are returned.
///
+ ///
+ /// Under mcp serve, where this state belongs to the connection, a client that supports native
+ /// roots but has not been asked yet or could not be reached leaves nothing resolved, and soft roots
+ /// stand in for that — so an empty result means the roots in force are empty, not that resolving them
+ /// failed. On a reused BuildMcpServerOptions() result the state belongs to the request instead,
+ /// and a roots-capable client reads empty until has been called within that
+ /// request; soft roots answer only when the client supports no native roots at all. Either way, call
+ /// when the difference matters: it resolves on demand and surfaces a failure
+ /// instead of absorbing it.
+ ///
+ /// Soft roots are the exception on that path: they are host-set state with no request to belong to,
+ /// so every connection built from one BuildMcpServerOptions() result shares the ones any of
+ /// them set. See the known limitation in docs/mcp-transports.md.
+ ///
+ ///
IReadOnlyList Current { get; }
///
@@ -27,7 +42,9 @@ public interface IMcpClientRoots
ValueTask> GetAsync(CancellationToken cancellationToken = default);
///
- /// Sets soft roots for the current session.
+ /// Sets soft roots for the current session — which under mcp serve is the connection, and on
+ /// a reused BuildMcpServerOptions() result is every connection built from it. See
+ /// .
///
void SetSoftRoots(IEnumerable roots);
diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs
index 3fff6a16..2d9237d9 100644
--- a/src/Repl.Mcp/IMcpFeedback.cs
+++ b/src/Repl.Mcp/IMcpFeedback.cs
@@ -1,5 +1,4 @@
-using ModelContextProtocol.Protocol;
-using Repl.Interaction;
+using Repl.Interaction;
namespace Repl.Mcp;
@@ -17,8 +16,30 @@ public interface IMcpFeedback
bool IsProgressSupported { get; }
///
- /// Gets a value indicating whether the connected MCP client can receive logging/message notifications.
+ /// Gets a value indicating whether the current request has a severity threshold at all, so a
+ /// message at or above it would reach the connected MCP client as a notification.
///
+ ///
+ /// On the 2026-07-28 revision this is unless the request declared
+ /// a log level in its metadata, because the specification forbids emitting message notifications
+ /// for a request that did not ask for them. A message sent while this is
+ /// during a tool call is not lost: it is carried back in the tool result instead. A resource
+ /// read has no such place to put it — a resource body must match its advertised MIME type — so a
+ /// message reported from a command serving a resource is dropped when the read succeeds. A read that
+ /// fails has no body at all, and carries the message in the surfaced error instead.
+ ///
+ /// A threshold existing is not a promise that every message arrives. An initialize-era host that
+ /// asked for Error leaves this while anything below that level is
+ /// dropped — and dropped messages are not carried back in the tool result, because the
+ /// client asked not to receive them.
+ ///
+ ///
+ /// A module presence predicate reads instead, on every revision: discovery
+ /// on 2026-07-28 answers every per-connection question with a constant, and this one is
+ /// answered as supported. Whatever the predicate decides under that answer is what every client is
+ /// offered — so gating a module on this member hides it from nobody.
+ ///
+ ///
bool IsLoggingSupported { get; }
///
@@ -29,10 +50,16 @@ ValueTask ReportProgressAsync(
CancellationToken cancellationToken = default);
///
- /// Sends a structured MCP message notification to the connected client.
+ /// Sends a message to the connected client, as a notification when the request asked for one and
+ /// otherwise as part of the result.
///
+ ///
+ /// A resource read that succeeds is the one path that keeps only its body, whose MIME type it has
+ /// already advertised, and drops what was buffered; a read that fails carries it in the surfaced
+ /// error. Everywhere else an undeliverable message is appended to the result rather than lost.
+ ///
ValueTask SendMessageAsync(
- LoggingLevel level,
+ McpMessageLevel level,
object? data,
CancellationToken cancellationToken = default);
}
diff --git a/src/Repl.Mcp/McpAppResource.cs b/src/Repl.Mcp/McpAppResource.cs
index f9370aa5..015e9c62 100644
--- a/src/Repl.Mcp/McpAppResource.cs
+++ b/src/Repl.Mcp/McpAppResource.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -7,12 +8,17 @@ internal sealed class McpAppResource : McpServerResource
{
private readonly McpAppResourceRegistration _registration;
private readonly IServiceProvider _services;
+ private readonly McpRequestServerAccessor _servers;
private readonly ResourceTemplate _protocolResourceTemplate;
- public McpAppResource(McpAppResourceRegistration registration, IServiceProvider services)
+ public McpAppResource(
+ McpAppResourceRegistration registration,
+ IServiceProvider services,
+ McpRequestServerAccessor servers)
{
_registration = registration;
_services = services;
+ _servers = servers;
_protocolResourceTemplate = new ResourceTemplate
{
Name = registration.Options.Name ?? registration.Uri,
@@ -30,20 +36,82 @@ public McpAppResource(McpAppResourceRegistration registration, IServiceProvider
public override bool IsMatch(string uri) =>
string.Equals(uri, _registration.Uri, StringComparison.OrdinalIgnoreCase);
+ ///
+ /// Surfaces a failed read together with whatever feedback the handler buffered, or leaves the
+ /// exception alone when it buffered none.
+ ///
+ ///
+ /// A failed read has no body to carry what the handler reported, so the surfaced error is the only
+ /// place left for it — the same treatment the command-backed resource paths give it. Only the
+ /// app-authored feedback travels: the handler's own message stays behind, because it can name a path
+ /// from an or a parameter and its full CLR type from a binding failure.
+ /// Withholding it is not a courtesy but a matter of not undoing the SDK, which flattens any
+ /// non- to "An error occurred." and passes an 's
+ /// message through verbatim — so wrapping is the act that would disclose it, and returning without
+ /// throwing is what leaves the empty case sanitized. The same rule as
+ /// McpServerHandler.ThrowSanitizedIfAClientAlreadyHasASchema.
+ ///
+ /// An is the exception to that: raising one is a deliberate act and its
+ /// message was written for this client, which is why the SDK lets it through. Replacing it because
+ /// the handler also reported something would make the feedback cost the explanation — and the same
+ /// failure without feedback would explain itself, which no caller could account for.
+ ///
+ ///
+ private static void ThrowIfFeedbackBuffered(
+ Exception exception,
+ McpFeedbackService.UndeliveredMessageScope undelivered)
+ {
+ var drained = undelivered.Messages.Drain();
+ if (drained.Count == 0)
+ {
+ return;
+ }
+
+ var surfaced = exception is McpException ? exception.Message : "MCP App resource read failed.";
+ throw new McpException(McpToolAdapter.AppendMessages(surfaced, drained), exception);
+ }
+
public override async ValueTask ReadAsync(
RequestContext request,
CancellationToken cancellationToken = default)
{
- var html = await McpAppResourceInvoker
- .InvokeAsync(
- _registration.Handler,
- _services,
- new McpAppResourceContext(request.Params.Uri),
- request,
- cancellationToken)
- .ConfigureAwait(false);
-
- return new ReadResourceResult
+ // Like every other prebuilt primitive: the reusable-options path dispatches straight into this
+ // resource, so without binding here a handler injecting IMcpClientRoots, IMcpSampling,
+ // IMcpElicitation or IMcpFeedback sees no flowing request and reports the client as incapable.
+ _servers.BindRequest(request);
+
+ // This is the one primitive that does not run through McpToolAdapter, so the execution prologue
+ // every command-backed path gets has to be repeated here: roots primed so a handler reading
+ // Current sees them, and a buffer open so feedback the client cannot receive as a notification
+ // is not simply lost.
+ await McpClientRootsService.PrimeFromServicesAsync(_services, cancellationToken).ConfigureAwait(false);
+ var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService;
+ using var undelivered = feedbackService?.PushUndeliveredMessages();
+
+ string html;
+ try
+ {
+ html = await McpAppResourceInvoker
+ .InvokeAsync(
+ _registration.Handler,
+ _services,
+ new McpAppResourceContext(request.Params.Uri),
+ request,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ // Cancellation is told apart by who asked for it, not by the exception's type: a handler that runs
+ // its own budget and gives up reports a failure like any other and its feedback still matters. Only
+ // the caller abandoning the request takes the bare path, which is the same rule
+ // McpClientRootsService.PrimeFromServicesAsync applies.
+ catch (Exception exception) when (undelivered is not null
+ && (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested))
+ {
+ ThrowIfFeedbackBuffered(exception, undelivered);
+ throw;
+ }
+
+ return McpCacheHints.MarkPrivateToThisClient(request, new ReadResourceResult
{
Contents =
[
@@ -55,6 +123,6 @@ public override async ValueTask ReadAsync(
Meta = McpAppMetadata.BuildResourceMeta(_registration.Options),
},
],
- };
+ });
}
}
diff --git a/src/Repl.Mcp/McpCacheHints.cs b/src/Repl.Mcp/McpCacheHints.cs
new file mode 100644
index 00000000..5f021b71
--- /dev/null
+++ b/src/Repl.Mcp/McpCacheHints.cs
@@ -0,0 +1,43 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Repl.Mcp;
+
+///
+/// Cache hints Repl attaches to results whose content depends on which client asked.
+///
+internal static class McpCacheHints
+{
+ ///
+ /// Marks as belonging to the requesting client alone, and as immediately
+ /// stale — on the revisions that have somewhere to put that.
+ ///
+ ///
+ /// Set rather than left to a default, because the default is the wrong one: SEP-2549 reads an
+ /// absent cacheScope as Public, and everything this package returns can vary by
+ /// client — a command graph gated on that client's roots, a resource body produced by running a
+ /// command for it. A shared gateway is entitled to serve a Public result to the next caller.
+ /// Applied at the primitives as well as the handler, because a server built from
+ /// BuildMcpServerOptions() dispatches straight into the primitives and never reaches a
+ /// handler.
+ ///
+ /// Both fields arrived with 2026-07-28 and are absent from the initialize-era result schema,
+ /// so an older client is left untagged. It cannot be reached through a cache that understands these
+ /// hints anyway, and a strict implementation may reject a response carrying a field its schema does
+ /// not define — on the compatibility path this package exists to keep working.
+ ///
+ ///
+ public static TResult MarkPrivateToThisClient(MessageContext request, TResult result)
+ where TResult : ICacheableResult
+ {
+ var protocolVersion = (request.JsonRpcMessage as JsonRpcRequest)?.Context?.ProtocolVersion;
+ if (!McpProtocolRevisions.CarriesSessionlessFields(protocolVersion))
+ {
+ return result;
+ }
+
+ result.CacheScope = CacheScope.Private;
+ result.TimeToLive = TimeSpan.Zero;
+ return result;
+ }
+}
diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs
index fa53d4e3..058b34f3 100644
--- a/src/Repl.Mcp/McpClientRootsService.cs
+++ b/src/Repl.Mcp/McpClientRootsService.cs
@@ -1,24 +1,68 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts.
+// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51).
+#pragma warning disable MCP9005
+
namespace Repl.Mcp;
+// Caches native roots for exactly as long as its McpRootsScope allows, and no longer. Outbound
+// transport goes through the request-bound accessor under either scope: the destination is per
+// request, which is finer than a session.
internal sealed class McpClientRootsService : IMcpClientRoots
{
private readonly ICoreReplApp _app;
+ private readonly McpRequestServerAccessor _servers;
+ private readonly McpRootsScope _scope;
private readonly Lock _syncRoot = new();
- private McpServer? _server;
+ // Bounds the one outbound call this type makes. Request scope pays it per request rather than once
+ // per connection, so a client that never answers roots/list would otherwise hold every tool call
+ // that resolves roots open with nothing to stop it.
+ private static readonly TimeSpan RootsRequestBudget = TimeSpan.FromSeconds(10);
+ // How long the eager prime stands down after an expensive failure. A client that declares the roots
+ // capability and then never answers would otherwise cost the full budget above on every single
+ // execution, for the life of the connection: nothing caches a failure, and the shared attempt is
+ // retracted at the next acquisition precisely so the next caller retries. That retry is right for a
+ // handler that asks for roots and wrong for the prime, which asks on nobody's behalf.
+ private static readonly TimeSpan PrimeRetryCooldown = TimeSpan.FromSeconds(30);
+
+ // How many times one call will ask before giving up. A roots/list_changed processed while a fetch is
+ // unanswered retires that fetch's answer, and the caller must not be handed it: the client has said
+ // those roots no longer apply, and nothing in the value distinguishes it from a current one. Asking
+ // again is the only way to honour the call, and the count bounds it because the client decides how
+ // often it invalidates.
+ private const int MaxRootsFetchAttempts = 3;
+
+ // Only a failure that actually spent the budget is worth standing down for. A client that answers
+ // roots/list promptly with something unusable costs nothing to ask again, and backing off there
+ // would just hold Current empty for half a minute after a fault that may already have cleared.
+ private static readonly TimeSpan PrimeStandDownThreshold = TimeSpan.FromMilliseconds(
+ RootsRequestBudget.TotalMilliseconds / 2);
+
+ // Request scope only. Keyed by the flowing request, so entries die with it and nothing here ever
+ // needs invalidating.
+ private readonly ConditionalWeakTable _requestRoots = new();
+ // Connection scope only.
private McpClientRoot[] _hardRoots = [];
private McpClientRoot[] _softRoots = [];
private bool _hardRootsLoaded;
+ private Task>? _hardRootsPending;
private long _hardRootsVersion;
+ // When the eager prime last failed, so it can stop paying the full budget on every execution.
+ private long? _primeFailedAt;
- public McpClientRootsService(ICoreReplApp app)
+ public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers, McpRootsScope scope)
{
_app = app;
+ _servers = servers;
+ _scope = scope;
}
- public bool IsSupported => _server?.ClientCapabilities?.Roots is not null;
+ public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null;
public bool HasSoftRoots
{
@@ -35,39 +79,162 @@ public IReadOnlyList Current
{
get
{
+ if (_scope is McpRootsScope.Request)
+ {
+ // Only what this request already resolved. Answering with another connection's cached
+ // roots is the same disclosure as GetAsync's, reached without any round-trip at all.
+ // One read of the flowing request, like GetAsync below.
+ if (_servers.Current is not { } request)
+ {
+ return GetSoftRoots();
+ }
+
+ return _requestRoots.TryGetValue(request, out var entry) && entry.Resolved is { } resolved
+ ? resolved
+ : request.Server.ClientCapabilities?.Roots is not null ? [] : GetSoftRoots();
+ }
+
lock (_syncRoot)
{
- return IsSupported ? _hardRoots : _softRoots;
+ // Native roots stand in for soft ones only once they have actually been resolved. Until
+ // then — never primed, or primed and failed — _hardRoots is empty, and answering with it
+ // would report "this client declared no roots" for what is really "nobody could ask it",
+ // which is the reading a handler is most likely to act on and the one it cannot check.
+ // A client that genuinely answers with zero roots sets _hardRootsLoaded, so that case is
+ // still told apart from this one.
+ return IsSupported && _hardRootsLoaded ? _hardRoots : _softRoots;
}
}
}
- public void AttachServer(McpServer server)
+ ///
+ /// Resolves this connection's native roots so that answers with them without
+ /// the caller having to ask first. A no-op outside connection scope.
+ ///
+ ///
+ /// Called at the command execution boundary. Connection scope is where promises
+ /// the session's roots, so a handler reading it must not have to prime the cache itself; the answer is
+ /// then cached for the life of the connection, which is one roots/list — the same cost as the
+ /// discovery-time pre-resolution this replaces. Request scope is deliberately excluded: there
+ /// is documented as only what this request already resolved, and an eager fetch
+ /// would add a round-trip to every request rather than to every connection.
+ ///
+ internal async ValueTask PrimeCurrentAsync(CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(server);
- _server = server;
+ if (_scope is not McpRootsScope.Connection || !IsSupported)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ // Standing down is only ever right while there is still nothing cached; once a fetch has
+ // succeeded GetAsync answers from the cache and costs nothing to call.
+ if (!_hardRootsLoaded
+ && _primeFailedAt is { } failedAt
+ && Stopwatch.GetElapsedTime(failedAt) < PrimeRetryCooldown)
+ {
+ return;
+ }
+ }
+
+ var startedAt = Stopwatch.GetTimestamp();
+ long versionAtStart;
+ lock (_syncRoot)
+ {
+ versionAtStart = _hardRootsVersion;
+ }
+
+ try
+ {
+ await GetAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ if (Stopwatch.GetElapsedTime(startedAt) >= PrimeStandDownThreshold)
+ {
+ lock (_syncRoot)
+ {
+ // Not if the client said its roots changed while this attempt was running. The
+ // notification clears the stand-down on purpose — whatever made the attempt fail may be
+ // exactly what it is reporting — and an attempt that began before it must not put the
+ // stand-down back afterwards, which is what a slow failure would otherwise do. Then no
+ // execution would prime for half a minute despite the client having asked for it.
+ if (_hardRootsVersion == versionAtStart)
+ {
+ _primeFailedAt = Stopwatch.GetTimestamp();
+ }
+ }
+ }
+
+ throw;
+ }
+
+ lock (_syncRoot)
+ {
+ _primeFailedAt = null;
+ }
}
public async ValueTask> GetAsync(CancellationToken cancellationToken = default)
{
- var server = _server;
- if (server?.ClientCapabilities?.Roots is null)
+ // Single read: the effective server must not change between the support check and
+ // the roots request (a concurrent request re-binding the accessor must not be observed).
+ if (_servers.Current is not { } request || request.Server.ClientCapabilities?.Roots is null)
{
return Current;
}
- long versionAtStart;
- lock (_syncRoot)
+ var server = request.Server;
+ if (_scope is McpRootsScope.Request)
{
- if (_hardRootsLoaded)
+ // The table hands every caller in this request the same entry; the entry, not the table's
+ // factory, starts the fetch. ConditionalWeakTable.GetValue documents that it may run its
+ // factory more than once for one key and outside its own lock, so a factory that started
+ // work would issue a second roots/list and orphan one of the two results.
+ var entry = _requestRoots.GetValue(request, static _ => new RequestRoots());
+ return await entry.ResolveAsync(server, cancellationToken).ConfigureAwait(false);
+ }
+
+ for (var attempt = 1; ; attempt++)
+ {
+ Task> pending;
+ lock (_syncRoot)
{
- return _hardRoots;
+ if (_hardRootsLoaded)
+ {
+ return _hardRoots;
+ }
+
+ var version = _hardRootsVersion;
+ pending = JoinOrStartAsync(ref _hardRootsPending, () => FetchHardRootsOnceAsync(server, version));
}
- versionAtStart = _hardRootsVersion;
- }
+ // Waited on this caller's token while the fetch runs on its own budget, the same shape request
+ // scope uses: concurrent first calls share one roots/list, and a caller giving up releases only
+ // itself. The answer is read back from the cache rather than from the task, because whether it
+ // was cached is exactly what says it is still current.
+#pragma warning disable VSTHRD003 // Started by this instance, a few lines above.
+ await pending.WaitAsync(cancellationToken).ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+
+ lock (_syncRoot)
+ {
+ if (_hardRootsLoaded)
+ {
+ return _hardRoots;
+ }
+ }
- return await GetAndMaybeCacheRootsAsync(server, versionAtStart, cancellationToken).ConfigureAwait(false);
+ // Nothing cached means the version moved while that fetch was unanswered, so the answer it
+ // carries was retracted before it arrived. Refusing to cache it is not enough: handing it back
+ // gives this caller roots the client has withdrawn, and nothing in the value says so.
+ if (attempt >= MaxRootsFetchAttempts)
+ {
+ throw new McpException("Client roots changed repeatedly while they were being resolved.");
+ }
+ }
}
public void SetSoftRoots(IEnumerable roots)
@@ -109,6 +276,9 @@ public void ClearSoftRoots()
}
}
+ // Reached only under connection scope: the notification handler is registered from AttachSession,
+ // which never runs on the path that builds a request-scoped service. Clearing the connection fields
+ // is harmless either way, since request scope never writes them.
public void HandleRootsListChanged()
{
lock (_syncRoot)
@@ -116,20 +286,148 @@ public void HandleRootsListChanged()
_hardRoots = [];
_hardRootsLoaded = false;
_hardRootsVersion++;
+
+ // Retired with the array it produced, and under the same lock. The task is kept to coalesce
+ // concurrent first calls; leaving a COMPLETED one here would make the next execution replay
+ // the pre-notification answer and send no roots/list at all, so the cache would be cleared
+ // with nothing left to refill it. A fetch still in flight is abandoned rather than awaited:
+ // it started before the change and the version check already stops it caching.
+ _hardRootsPending = null;
+
+ // The client has just said something changed, which is the one signal worth interrupting the
+ // prime's stand-down for: whatever made the last attempt fail may be what it is reporting.
+ _primeFailedAt = null;
}
_app.InvalidateRouting();
}
- private async ValueTask> GetAndMaybeCacheRootsAsync(
+ private McpClientRoot[] GetSoftRoots()
+ {
+ lock (_syncRoot)
+ {
+ return _softRoots;
+ }
+ }
+
+ private static async Task FetchRootsAsync(
McpServer server,
- long versionAtStart,
CancellationToken cancellationToken)
{
var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken)
.ConfigureAwait(false);
- var mappedRoots = result.Roots?.Select(MapRoot).ToArray() ?? [];
+ return result.Roots?.Select(MapRoot).ToArray() ?? [];
+ }
+ ///
+ /// Joins the attempt already outstanding in , or starts one when there is
+ /// none left to join.
+ ///
+ ///
+ /// Both scopes coalesce their concurrent first callers onto a single roots/list, and the rule
+ /// for doing it is subtle enough that keeping two copies of it has cost this branch three rounds of
+ /// fixing one and missing the other. A failed attempt is retracted here, at acquisition, rather than
+ /// where a waiter observes the failure: the last waiter can abandon its wait while the attempt is
+ /// still running, and then nothing is left to retract it when it faults afterwards. The fault is
+ /// spoken for on the way out for that same reason. Callers hold their own lock across this, which is
+ /// what makes the decision atomic against whatever else that lock guards, and each then waits on its
+ /// own token so that one caller giving up releases only itself.
+ ///
+ private static Task JoinOrStartAsync(ref Task? pending, Func> start)
+ {
+ if (pending is { IsCompleted: true } settled && !settled.IsCompletedSuccessfully)
+ {
+ pending = null;
+ }
+
+ if (pending is null)
+ {
+ pending = start();
+ ObserveFault(pending);
+ }
+
+ return pending;
+ }
+
+ ///
+ /// Speaks for 's fault, so that nobody has to.
+ ///
+ ///
+ /// Every waiter leaves on its own token, so a shared fetch can fault with nobody left to read it,
+ /// and it is then dropped unread — replaced at the next acquisition, retired by
+ /// , or simply collected when the connection ends. Without this
+ /// the exception reaches from a finalizer,
+ /// long after the request that caused it, and a host configured to throw on that crashes. The idiom
+ /// matches ReplProcessSignalHarness's.
+ ///
+ private static void ObserveFault(Task fetch) =>
+ _ = fetch.ContinueWith(
+ static observed => _ = observed.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+
+ ///
+ /// The one outstanding connection-scoped fetch, shared by every caller that arrives before it
+ /// completes.
+ ///
+ ///
+ /// Without this, two first invocations on one connection each send their own roots/list and
+ /// one result is discarded, which the "one round-trip per connection" cost claim does not allow. It
+ /// carries its own budget for the same reason request scope does: the result belongs to every
+ /// caller, so no single caller's token may bound it.
+ ///
+ private async Task> FetchHardRootsOnceAsync(McpServer server, long versionAtStart)
+ {
+ using var budget = new CancellationTokenSource(RootsRequestBudget);
+ return await GetAndMaybeCacheRootsAsync(server, versionAtStart, budget.Token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Primes the connection's native roots from , swallowing a failure.
+ ///
+ ///
+ /// Called from every execution entry point. A handler that never reads roots must not fail because
+ /// the client could not answer, and one that does read them surfaces the error from its own
+ /// . Cancellation is the caller's and propagates.
+ ///
+ internal static async ValueTask PrimeFromServicesAsync(
+ IServiceProvider services,
+ CancellationToken cancellationToken)
+ {
+ if (services.GetService(typeof(IMcpClientRoots)) is not McpClientRootsService roots)
+ {
+ return;
+ }
+
+ try
+ {
+ await roots.PrimeCurrentAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // Swallowed: see the remarks above.
+ }
+ }
+
+ private async ValueTask> GetAndMaybeCacheRootsAsync(
+ McpServer server,
+ long versionAtStart,
+ CancellationToken cancellationToken)
+ {
+ var mappedRoots = await FetchRootsAsync(server, cancellationToken).ConfigureAwait(false);
+
+ // Caching is refused for an answer whose version was retired while it was still outstanding: it
+ // would pin roots the client has already retracted, with nothing left to refill them. The refusal
+ // is also the signal GetAsync reads — an uncached answer is a retracted one, and that caller asks
+ // again rather than receive it. The returned value is therefore only meaningful when it was
+ // cached. Ordering a roots/list_changed against an unanswered fetch is awkward but not impossible:
+ // the guard holds the answer until the server echoes tools/list_changed, which it emits from the
+ // same handler that moves the version.
lock (_syncRoot)
{
if (_hardRootsVersion == versionAtStart)
@@ -143,6 +441,46 @@ private async ValueTask> GetAndMaybeCacheRootsAsync
}
}
+ ///
+ /// One request's native roots: fetched at most once, and forgotten with the request.
+ ///
+ private sealed class RequestRoots
+ {
+ private readonly Lock _gate = new();
+ private Task? _pending;
+ private McpClientRoot[]? _resolved;
+
+ /// What this request has settled on, or while it has not.
+ public McpClientRoot[]? Resolved => Volatile.Read(ref _resolved);
+
+ public async Task> ResolveAsync(
+ McpServer server,
+ CancellationToken cancellationToken)
+ {
+ Task pending;
+ lock (_gate)
+ {
+ pending = JoinOrStartAsync(ref _pending, () => FetchOnceAsync(server));
+ }
+
+ // Waited on this caller's token while the fetch itself runs on its own budget: one caller
+ // giving up must release that caller, and must not cancel the result the others share.
+#pragma warning disable VSTHRD003 // Started by this instance, for this request, one line above.
+ return await pending.WaitAsync(cancellationToken).ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+
+ private async Task FetchOnceAsync(McpServer server)
+ {
+ // Its own budget rather than a caller's token: the result is shared by every caller in this
+ // request, so cancelling one must not cancel the others, and nothing else bounds the wait.
+ using var budget = new CancellationTokenSource(RootsRequestBudget);
+ var roots = await FetchRootsAsync(server, budget.Token).ConfigureAwait(false);
+ Volatile.Write(ref _resolved, roots);
+ return roots;
+ }
+ }
+
private static McpClientRoot MapRoot(Root root)
{
var uri = Uri.TryCreate(root.Uri, UriKind.Absolute, out var parsed)
diff --git a/src/Repl.Mcp/McpDiscoveryCapabilities.cs b/src/Repl.Mcp/McpDiscoveryCapabilities.cs
new file mode 100644
index 00000000..27971c8a
--- /dev/null
+++ b/src/Repl.Mcp/McpDiscoveryCapabilities.cs
@@ -0,0 +1,220 @@
+using Repl.Interaction;
+using Repl.Terminal;
+
+namespace Repl.Mcp;
+
+///
+/// The session-scoped services as discovery sees them on a modern revision: constants, holding
+/// no reference to the live services, so the advertised command graph depends only on
+/// application-global state.
+///
+///
+/// Revision 2026-07-28 conveys version, identity and capabilities as per-request metadata and
+/// has no session, and its tools chapter requires that the advertised set
+/// "MUST NOT vary per-connection or as a side effect of other requests on the connection" —
+/// the one stated exception being the authorization presented on the request.
+///
+/// These wrappers therefore delegate nothing. Holding no inner service makes the
+/// invariant structural rather than a property each member has to keep: whichever member a predicate
+/// reaches for — IsSupported, HasSoftRoots, Current, GetAsync — there is
+/// nothing per-connection behind it.
+///
+///
+/// The capability questions answer "available" so that a gated command is advertised rather than
+/// silently dropped, and the data questions answer "nothing", which is the only constant a list can
+/// honestly take. The action members are inert: a presence predicate must not reach the client at all,
+/// since doing so would be both a per-connection dependency and a side effect of discovery.
+///
+///
+/// Execution binds handlers from the real services, so a command that needs roots and is called by a
+/// client without them runs and can report exactly that, and soft roots set by a command remain fully
+/// visible through there. Execution does take these answers for
+/// deciding presence, so that what was advertised is what can be called; deciding it twice
+/// from two views is what would make a tool visible and unreachable. Only the automatic revealing of
+/// commands goes away.
+///
+///
+/// The legacy revisions establish a session with an initialize handshake and state no such
+/// invariant, so they keep the per-session view: these wrappers are applied on modern requests only.
+///
+///
+internal static class McpDiscoveryCapabilities
+{
+ public static IMcpClientRoots Roots { get; } = new DiscoveryClientRoots();
+
+ public static IMcpSampling Sampling { get; } = new DiscoverySampling();
+
+ public static IMcpElicitation Elicitation { get; } = new DiscoveryElicitation();
+
+ public static IMcpFeedback Feedback { get; } = new DiscoveryFeedback();
+
+ ///
+ /// Session state as discovery sees it: empty, and unchanged by anything written to it.
+ ///
+ ///
+ /// The capability services cover the per-connection half of the rule. This covers the other half,
+ /// which needs no second connection to be observable: session state is a mutable singleton that a
+ /// command can write, and a predicate reading it would let a tools/call decide what the next
+ /// tools/list advertises — precisely the side effect the revision forbids. Reads answer
+ /// "absent" because that is the only constant a store of arbitrary keys can honestly take; writes
+ /// are inert, since a predicate must not mutate what it is measuring.
+ ///
+ public static IReplSessionState SessionState { get; } = new DiscoverySessionState();
+
+ ///
+ /// Session metadata as discovery sees it: nothing known.
+ ///
+ ///
+ /// The live implementation is a façade over the ambient session, so its answers move with whichever
+ /// connection happens to be resolving. A predicate gated on a terminal size or a transport name
+ /// would therefore vary per connection, which is the first half of the rule.
+ ///
+ public static IReplSessionInfo SessionInfo { get; } = new DiscoverySessionInfo();
+
+ private sealed class DiscoveryClientRoots : IMcpClientRoots
+ {
+ public bool IsSupported => true;
+
+ public bool HasSoftRoots => false;
+
+ public IReadOnlyList Current => [];
+
+ public ValueTask> GetAsync(CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult>([]);
+
+ public void SetSoftRoots(IEnumerable roots)
+ {
+ // Inert: discovery must not mutate the state it is projecting.
+ }
+
+ public void ClearSoftRoots()
+ {
+ // Inert, for the same reason as SetSoftRoots.
+ }
+ }
+
+ ///
+ /// The frozen answers as a set, for the two places that must agree on them.
+ ///
+ /// How an unanswerable prompt resolves; see .
+ ///
+ /// Both views must answer alike, for the reason the type remarks give. A fresh dictionary per call,
+ /// because the overlay owns what it is given.
+ ///
+ /// The interaction channel belongs in the set for the same reason the capability services do: a
+ /// predicate may ask a question, and the live channel answers from the call's own
+ /// answer.* arguments — which would let a tool argument decide whether the tool it was
+ /// passed to exists.
+ ///
+ ///
+ public static IReadOnlyDictionary CreateSessionScopedOverrides(
+ InteractivityMode interactivityMode) => new Dictionary
+ {
+ [typeof(IReplInteractionChannel)] = CreateDiscoveryChannel(interactivityMode),
+ [typeof(IMcpClientRoots)] = Roots,
+ [typeof(IMcpSampling)] = Sampling,
+ [typeof(IMcpElicitation)] = Elicitation,
+ [typeof(IMcpFeedback)] = Feedback,
+ [typeof(IReplSessionState)] = SessionState,
+ [typeof(IReplSessionInfo)] = SessionInfo,
+ };
+
+ ///
+ /// The channel a presence predicate is asked through: no prefilled answers, and no client behind
+ /// it to elicit or sample from, so a question resolves to its declared default.
+ ///
+ ///
+ /// What a question with no default does. It is the host's configured mode rather than a constant
+ /// so that discovery and execution fail the same way on the same predicate.
+ ///
+ /// A fresh channel; it holds no answer, so instances are interchangeable.
+ public static McpInteractionChannel CreateDiscoveryChannel(InteractivityMode interactivityMode) =>
+ new(new Dictionary(StringComparer.Ordinal), interactivityMode);
+
+ private sealed class DiscoverySessionState : IReplSessionState
+ {
+ public bool TryGet(string key, out T? value)
+ {
+ value = default;
+ return false;
+ }
+
+ public T? Get(string key) => default;
+
+ public void Set(string key, T value)
+ {
+ // Inert: a predicate must not mutate what it is measuring.
+ }
+
+ public bool Remove(string key) => false;
+
+ public void Clear()
+ {
+ // Inert, for the same reason as Set.
+ }
+ }
+
+ private sealed class DiscoverySessionInfo : IReplSessionInfo
+ {
+ public (int Width, int Height)? WindowSize => null;
+
+ public bool AnsiSupported => false;
+
+ public string? TransportName => null;
+
+ public string? RemotePeer => null;
+
+ public TerminalCapabilities TerminalCapabilities => TerminalCapabilities.None;
+
+ public string? TerminalIdentity => null;
+
+ public string? ShellIntegrationStatus => null;
+ }
+
+ private sealed class DiscoverySampling : IMcpSampling
+ {
+ public bool IsSupported => true;
+
+ public ValueTask SampleAsync(
+ string prompt,
+ int maxTokens = 1024,
+ CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(null);
+ }
+
+ private sealed class DiscoveryElicitation : IMcpElicitation
+ {
+ public bool IsSupported => true;
+
+ public ValueTask ElicitTextAsync(string message, CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(null);
+
+ public ValueTask ElicitBooleanAsync(string message, CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(null);
+
+ public ValueTask ElicitChoiceAsync(
+ string message,
+ IReadOnlyList choices,
+ CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(null);
+
+ public ValueTask ElicitNumberAsync(string message, CancellationToken cancellationToken = default) =>
+ ValueTask.FromResult(null);
+ }
+
+ private sealed class DiscoveryFeedback : IMcpFeedback
+ {
+ public bool IsProgressSupported => true;
+
+ public bool IsLoggingSupported => true;
+
+ public ValueTask ReportProgressAsync(
+ ReplProgressEvent progress,
+ CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
+
+ public ValueTask SendMessageAsync(
+ McpMessageLevel level,
+ object? data,
+ CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs
index 12de5219..40a57af9 100644
--- a/src/Repl.Mcp/McpElicitationService.cs
+++ b/src/Repl.Mcp/McpElicitationService.cs
@@ -15,13 +15,11 @@ namespace Repl.Mcp;
/// a multi-field variant would build the with
/// multiple properties instead of one.
///
-internal sealed class McpElicitationService : IMcpElicitation
+internal sealed class McpElicitationService(McpRequestServerAccessor servers) : IMcpElicitation
{
private const string FieldName = "value";
- private McpServer? _server;
-
- public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null;
+ public bool IsSupported => servers.Effective?.ClientCapabilities?.Elicitation is not null;
public async ValueTask ElicitTextAsync(
string message,
@@ -99,19 +97,19 @@ internal sealed class McpElicitationService : IMcpElicitation
: null;
}
- internal void AttachServer(McpServer server) => _server = server;
-
private async ValueTask ElicitSingleFieldAsync(
string message,
ElicitRequestParams.PrimitiveSchemaDefinition schema,
CancellationToken cancellationToken)
{
- if (!IsSupported)
+ // Single read: the effective server must not change between the support check and
+ // the call (a concurrent request re-binding the accessor must not be observed).
+ if (servers.Effective is not { ClientCapabilities.Elicitation: not null } server)
{
return null;
}
- var result = await _server!.ElicitAsync(
+ var result = await server.ElicitAsync(
new ElicitRequestParams
{
Message = message,
diff --git a/src/Repl.Mcp/McpExplicitPrompt.cs b/src/Repl.Mcp/McpExplicitPrompt.cs
new file mode 100644
index 00000000..3f57e2d3
--- /dev/null
+++ b/src/Repl.Mcp/McpExplicitPrompt.cs
@@ -0,0 +1,109 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Repl.Mcp;
+
+///
+/// Gives an explicitly registered prompt the execution prologue every command-backed path gets.
+///
+///
+/// A prompt registered through options.Prompt(...) is a raw SDK primitive: the SDK invokes its
+/// handler directly, so it never passes through . Without this it reaches
+/// the handler with the connection's roots unresolved — a handler reading
+/// would see an empty list and take it for a client that
+/// declared no workspace — and with no buffer open, so on 2026-07-28 a request that declared no
+/// log level loses its feedback silently, having no notification channel to receive it on.
+///
+/// carries the same prologue for the same reason. Those two are the
+/// prebuilt primitives that bypass the adapter; everything command-backed gets it from the adapter
+/// itself.
+///
+///
+internal sealed class McpExplicitPrompt(
+ McpServerPrompt inner,
+ IServiceProvider services,
+ McpRequestServerAccessor servers) : McpServerPrompt
+{
+ public override Prompt ProtocolPrompt => inner.ProtocolPrompt;
+
+ public override IReadOnlyList