You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/architecture.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,6 +115,11 @@ The toolkit provides two application entry points for different scenarios.
115
115
- Wraps `CoreReplApp` with `Microsoft.Extensions.DependencyInjection`
116
116
- Provides `UseDefaultInteractive()`, `UseCliProfile()`, and other composition profiles
117
117
- Lazily builds a shared `ServiceProvider` for module resolution and handler injection
118
+
- Opens one DI scope per session: each `Run*` call is a session, so `Scoped` services resolve per
119
+
session and their disposables are released when it ends. `ReplRunOptions.SessionScope` set to
120
+
`SessionScopeBehavior.CallerOwned` opts out when the caller's provider already *is* the session scope
121
+
(a Blazor circuit, an ASP.NET request scope, a session owner spanning several one-shot runs).
122
+
Hosted services start outside that scope, because their lifetime is the application's
118
123
- Best for: standalone CLI/REPL applications with service layers
119
124
120
125
**`InvalidateRouting()`** — call this when module presence conditions may have changed at runtime (e.g., feature flags toggled, dynamic module discovery). Increments the routing cache version so the next execution re-evaluates all module presence predicates.
Note: DI singleton factories are resolved lazily, so the values are available after global option parsing completes. However, singleton factories capture values once — in interactive mode, global options can change between commands. If your service needs to see updated values per command, inject `IGlobalOptionsAccessor` directly and read values at call time instead of capturing them in a factory. See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers).
109
109
110
+
## Pick the lifetime that matches the boundary
111
+
112
+
| Lifetime | Resolves once per | Use it for |
113
+
|---|---|---|
114
+
|`Singleton`| application | caches, clients, anything shared by every session |
115
+
|`Scoped`|**session** — one `Run*` call: a CLI invocation, an interactive run, a Telnet or WebSocket connection; under MCP, one client connection\*| per-user state: auth context, a cart, a unit of work |
A `Scoped` disposable is disposed when its session ends, so session resources do not accumulate for the
119
+
life of a long-running host.
120
+
121
+
\***Except a server built from a reused `BuildMcpServerOptions()` result** — the shape ASP.NET Core and
122
+
other custom-transport hosts use. That path has no per-connection context, so it has no per-connection DI
123
+
scope either: one `Scoped` instance is shared by every client it serves, disposed only when the process
124
+
ends. A `Scoped` auth-context or cart registered there leaks between clients. See the [known
125
+
limitation](mcp-conformance.md#deliberate-gaps) and [Isolation boundaries](mcp-transports.md#what-is-isolated-and-at-which-boundary).
126
+
127
+
Two concurrency notes that follow from the unit being the whole connection rather than one call: several
128
+
MCP tool calls can run at once on one connection, so a `Scoped` instance can be entered concurrently —
129
+
make it thread-safe, or serialize access to it yourself, the way a `DbContext` (a poor fit for concurrent
130
+
entry) would need. And on `mcp serve`, that `Scoped` instance is exactly what carries state from one tool
131
+
call to the next within a connection — the earlier calls in this section describing `Scoped` as
132
+
resolving "once per session" are what makes that possible.
133
+
134
+
The trap worth naming: a **singleton** that injects a `Scoped` service captures whichever scope first
135
+
resolved it, and keeps it for the life of the application — silently, because the provider is built
136
+
without `ValidateScopes`. Inject the scoped service into the handler instead, or register the holder
137
+
`Scoped` too.
138
+
139
+
The same trap catches a **module's own constructor**. `MapModule<TModule>()` resolves `TModule` once,
140
+
at mapping time — before any session exists — the same way a singleton would. A module constructor
141
+
that takes a `Scoped` auth-context or cart captures it exactly as the singleton case above does, and
142
+
every session's handlers then share that one instance. The [module example above](#structure-commands-with-modules)
143
+
avoids this because its handlers take `IContactStore` as a **handler parameter**, resolved fresh per
144
+
invocation — not as a constructor dependency. Keep any `Scoped` service out of a module's constructor;
145
+
inject it into the handler that needs it instead.
146
+
147
+
When your caller's provider already represents the session — a Blazor circuit, an ASP.NET request scope,
148
+
or a session owner running several one-shot calls — set
149
+
`ReplRunOptions.SessionScope = SessionScopeBehavior.CallerOwned` so the run resolves from it directly
150
+
instead of opening a second scope beside it. `IServiceScopeFactory` is itself a singleton, so that second
151
+
scope is not nested inside the caller's — it is a SIBLING rooted at the application root, and it does not
152
+
hide the caller's scoped instances so much as duplicate them: a second unit of work running alongside the
153
+
live one. `SessionScopeBehavior` governs the `Run*` family only; MCP's connection-scoped DI is unconditional
154
+
and has no equivalent opt-out.
155
+
110
156
## Group related options with `[ReplOptionsGroup]`
111
157
112
158
When a command has many options, group them into a class instead of listing them all as handler parameters. This keeps handlers clean and makes option sets reusable across commands.
Copy file name to clipboardExpand all lines: docs/mcp-conformance.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -129,6 +129,7 @@ returned by a creation tool and passed back as an argument, rather than implicit
129
129
| --- | --- | --- |
130
130
| 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)|
131
131
|`*/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)|
132
+
| A `Scoped` DI registration is shared by every client on the reusable-options path | That path has one handler-lifetime context serving all connections, so it has no per-connection scope to give `Scoped` — one instance lives for the process, same as the native roots cache and soft roots on the same path. A `Scoped` auth-context or cart registered there leaks between clients. See [best-practices.md](best-practices.md#pick-the-lifetime-that-matches-the-boundary). | — |
132
133
| 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). | — |
Copy file name to clipboardExpand all lines: docs/mcp-transports.md
+33-5Lines changed: 33 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -102,10 +102,38 @@ every request rather than once per connection. So the boundaries are not all the
102
102
|---|---|
103
103
| 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 |
104
104
| Invocation | I/O capture — each tool call gets its own capture scope, not one per connection |
105
-
| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, and session-aware routing state |
106
-
107
-
A server created from a reused `BuildMcpServerOptions()` result has everything above except the
108
-
connection row; see the known limitation above. That matters especially when using dynamic tools,
109
-
roots, or session-specific modules.
105
+
| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, session-aware routing state, `IReplSessionState`, and **the DI scope**: a `Scoped` registration resolves once per connection, and its disposables are released when the connection ends |
106
+
107
+
The DI scope is the connection's, for the same reason it is the session's on every other transport: a
108
+
`Scoped` service is where per-user state lives, and state that resets between two calls of one client
109
+
is not per-user state. It is also what keeps discovery and execution honest — both resolve a module
110
+
presence predicate's services from the same scope, so a command that was advertised can be called.
111
+
112
+
The MCP SDK opens a scope of its own per request (`McpServerOptions.ScopeRequests`, default `true`) and
113
+
exposes it as `RequestContext.Services`. Repl does not run commands in it: that scope descends from the
114
+
application root and is a sibling of the connection's, so adopting it would give the catalog and the
115
+
command two different instances of the same service. `BuildDynamicServerOptions` — the `mcp serve` path
116
+
that owns a real connection context — sets `ScopeRequests = false`, so the SDK does not open that
117
+
sibling scope there to begin with.
118
+
119
+
A server created from a reused `BuildMcpServerOptions()` result — which is how an ASP.NET Core or other
120
+
custom-transport host reaches Repl — has everything above **except the connection row**: see the known
121
+
limitation above. There is no per-connection context to give a DI scope to either, so `Scoped` resolves
122
+
once from the application root and is shared by every client the process serves, for as long as it runs.
123
+
That is true under stateless HTTP hosting too, even though the SDK itself builds one short-lived server
124
+
per request there: Repl's own scope is a property of the *context* it built at construction, not of the
125
+
SDK's per-request server, so it does not follow the SDK's request boundary.
126
+
127
+
**Calling `BuildMcpServerOptions()` again for each connection does not isolate `Scoped` either.** The
128
+
`ReplApp` overload always passes that app's one `Services` root (`app.Services`, cached for the app's
129
+
whole lifetime), so two calls against the same `ReplApp` still share the same root — and therefore the
130
+
same `Scoped` instances — no matter how often the method runs. Genuine per-connection isolation on this
131
+
path requires the host to supply a **different**`IServiceProvider` per connection, through the
132
+
`ICoreReplApp.BuildMcpServerOptions(configure, services)` overload: build a scope yourself (for example
133
+
`app.Services.GetRequiredService<IServiceScopeFactory>().CreateAsyncScope()`), pass its
134
+
`ServiceProvider` in for that connection, and dispose the scope yourself when the connection ends — Repl
135
+
does not manage that scope's lifetime on this path, the same way it does not manage the connection
136
+
itself. This is the "multiplexes connections over one options instance" case flagged as a caller
137
+
responsibility below, made concrete.
110
138
111
139
For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md).
0 commit comments