Skip to content

Commit bd66f5d

Browse files
authored
Merge pull request #11 from yllibed/dev/cdb/mcp-roots-and-compat
Add MCP client roots support and dynamic tool compatibility fallback
2 parents af2ca6d + e4f5f71 commit bd66f5d

28 files changed

Lines changed: 1650 additions & 326 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ Globex
8989
**MCP mode** (same command graph, exposed to AI agents):
9090

9191
```csharp
92+
using Repl.Mcp;
93+
9294
app.UseMcpServer(); // add one line
9395
```
9496

docs/mcp-advanced.md

Lines changed: 225 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,266 @@
1-
# MCP Advanced: Custom Transports & HTTP Integration
1+
# MCP Advanced: Dynamic Tools, Roots, and Session-Aware Patterns
22

3-
This guide covers two advanced integration scenarios beyond the default stdio transport.
3+
This guide covers advanced MCP usage patterns for Repl apps:
44

5-
> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basics of exposing a Repl app as an MCP server.
5+
- Tool visibility that changes per session
6+
- Native MCP client roots
7+
- Soft roots for clients that don't support roots
8+
- Compatibility shims for clients that don't refresh dynamic tool lists well
69

7-
## Scenario A: Stdio-over-anything
10+
> **Prerequisite**: read [mcp-server.md](mcp-server.md) first for the basic setup.
11+
>
12+
> **Need the plumbing details?** See [mcp-internals.md](mcp-internals.md).
13+
>
14+
> **Need custom transports or HTTP hosting?** See [mcp-transports.md](mcp-transports.md).
815
9-
The MCP protocol is JSON-RPC over stdin/stdout. The `TransportFactory` option lets you replace the physical transport while keeping the same protocol — useful for WebSocket bridges, named pipes, SSH tunnels, etc.
16+
## When this page matters
1017

11-
### How it works
18+
Most Repl MCP servers don't need any of this.
1219

13-
`TransportFactory` receives the server name and an I/O context, and returns an `ITransport`. The MCP server uses this transport instead of `StdioServerTransport`.
20+
Use the techniques in this page when:
21+
- Available tools depend on login state, tenant, feature flags, or workspace
22+
- The agent needs to know which directories it is allowed to work in
23+
- Your MCP client does not support native roots
24+
- Your MCP client does not seem to refresh its tool list after `list_changed`
25+
26+
If your tool list is static, stay with the default setup from [mcp-server.md](mcp-server.md).
27+
28+
## Client roots
29+
30+
A **root** is a workspace or directory that the MCP client declares as being in scope for the session.
31+
32+
Examples:
33+
- The folder the user opened in the editor
34+
- The project workspace attached to the agent
35+
- A set of directories the agent is allowed to inspect
36+
37+
When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`.
1438

1539
```csharp
16-
app.UseMcpServer(o =>
17-
{
18-
o.TransportFactory = (serverName, io) =>
40+
using Repl.Mcp;
41+
42+
app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) =>
1943
{
20-
// Bridge a WebSocket connection to MCP via streams.
21-
var (inputStream, outputStream) = CreateWebSocketBridge();
22-
return new StreamServerTransport(inputStream, outputStream, serverName);
23-
};
24-
});
44+
var current = await roots.GetAsync(ct);
45+
return current.Select(r => new { r.Name, Uri = r.Uri.ToString() });
46+
})
47+
.WithDescription("List the current MCP workspace roots")
48+
.ReadOnly();
2549
```
2650

27-
The app still launches via `myapp mcp serve` — the framework handles the full MCP lifecycle (tool registration, routing invalidation, shutdown). This approach gives you **one session per process**.
51+
Useful members:
52+
53+
| Member | Meaning |
54+
|---|---|
55+
| `IsSupported` | The connected client supports native MCP roots |
56+
| `Current` | Current effective roots for the session |
57+
| `GetAsync()` | Refreshes native roots if supported |
58+
| `HasSoftRoots` | Fallback roots were initialized manually |
59+
| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots for the current session |
2860

29-
### Multi-session (accept N connections)
61+
## Session-aware routing
3062

31-
For multiple concurrent sessions over a custom transport (e.g. a WebSocket listener accepting many clients), use `BuildMcpServerOptions` to build the options once, then create a server per connection:
63+
Because `IMcpClientRoots` is injectable, you can use it in command handlers and in module presence predicates.
64+
65+
That lets you expose tools only when a certain MCP capability or session state is available.
3266

3367
```csharp
34-
var mcpOptions = app.Core.BuildMcpServerOptions();
68+
using Repl.Mcp;
69+
70+
app.MapModule(
71+
new WorkspaceModule(),
72+
(IMcpClientRoots roots) => roots.IsSupported);
73+
```
74+
75+
Typical session-aware conditions:
76+
- Roots are available
77+
- Soft roots were initialized
78+
- The current tenant or login is known
79+
- A module should appear only for one agent session
80+
81+
## Guidance: MCP-only vs workspace-aware commands
82+
83+
`IMcpClientRoots` is MCP-scoped, but that does not automatically mean every command using it must be MCP-only.
3584

36-
// For each incoming WebSocket connection:
37-
async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken ct)
85+
There are two useful patterns:
86+
87+
### Pattern 1: MCP-only commands
88+
89+
Use this when the command only makes sense inside an MCP session.
90+
91+
```csharp
92+
using Repl.Mcp;
93+
94+
app.MapModule(
95+
new WorkspaceBootstrapModule(),
96+
(IMcpClientRoots? roots) => roots is not null);
97+
```
98+
99+
This is the simplest option when:
100+
- the command exists only to help an agent initialize MCP session state
101+
- the command depends directly on MCP capabilities
102+
- showing it in CLI or interactive Repl would be confusing
103+
104+
### Pattern 2: Workspace-aware commands
105+
106+
Use this when the command should work both inside and outside MCP.
107+
108+
In that case, treat MCP roots as just one possible source of workspace context, not the only source.
109+
110+
Typical workspace sources:
111+
112+
1. native MCP roots
113+
2. MCP soft roots
114+
3. session state in Repl
115+
4. a command-line argument or explicit option
116+
5. the process current directory
117+
118+
For example:
119+
120+
```csharp
121+
using Repl.Mcp;
122+
123+
app.Map("workspace status", async (IMcpClientRoots? roots, IReplSessionState state, CancellationToken ct) =>
124+
{
125+
var workspace =
126+
roots is not null
127+
? (await roots.GetAsync(ct)).FirstOrDefault()?.Uri?.ToString()
128+
: state.Get<string>("workspace.path");
129+
130+
return workspace is null
131+
? "No workspace selected."
132+
: $"Workspace: {workspace}";
133+
})
134+
.ReadOnly();
135+
```
136+
137+
And you can pair that with a general-purpose Repl command:
138+
139+
```csharp
140+
app.Map("workspace set {path}", (IReplSessionState state, string path) =>
141+
{
142+
state.Set("workspace.path", path);
143+
return "Workspace updated.";
144+
});
145+
```
146+
147+
This pattern is often better than making everything MCP-only.
148+
149+
### Recommendation
150+
151+
When a command needs a working directory or workspace, design it around a **workspace resolution strategy** instead of assuming one single source.
152+
153+
That usually makes the command:
154+
- more reusable
155+
- easier to test
156+
- usable from CLI, hosted sessions, and MCP
157+
- easier to adapt when some clients support roots and others do not
158+
159+
## Soft roots fallback
160+
161+
Some clients do not support MCP roots at all. In that case, a practical workaround is to expose an initialization tool only when roots are unavailable.
162+
163+
The agent can call that tool first to establish one or more **soft roots** for the session.
164+
165+
```csharp
166+
using Repl.Mcp;
167+
168+
app.MapModule(
169+
new SoftRootsInitModule(),
170+
(IMcpClientRoots roots) => !roots.IsSupported);
171+
172+
app.MapModule(
173+
new WorkspaceModule(),
174+
(IMcpClientRoots roots) => roots.IsSupported || roots.HasSoftRoots);
175+
176+
sealed class SoftRootsInitModule : IReplModule
38177
{
39-
var transport = new StreamServerTransport(input, output, "my-server");
40-
var server = McpServer.Create(transport, mcpOptions);
41-
await server.RunAsync(ct);
42-
await server.DisposeAsync();
178+
public void Map(IReplMap app)
179+
{
180+
app.Map("workspace init {path}", (IMcpClientRoots roots, string path) =>
181+
{
182+
// SetSoftRoots invalidates routing for the current MCP session.
183+
roots.SetSoftRoots([new McpClientRoot(new Uri(path, UriKind.Absolute), "workspace")]);
184+
return "Workspace initialized.";
185+
})
186+
// Message to agent asking it to set soft routes
187+
.WithDescription("Before using other workspace tools, call this to set the working directory.");
188+
}
43189
}
44190
```
45191

46-
Each session is fully isolated — tool invocations run in separate `AsyncLocal` scopes with their own I/O streams, just like hosted sessions.
192+
Recommended instruction to give the agent:
47193

48-
### When to use
194+
> If `workspace_init` is available, call it first with the working directory you should operate in.
49195
50-
- You have a non-stdio transport (WebSocket, named pipe, TCP) that carries the standard MCP JSON-RPC protocol
51-
- Single-session: use `TransportFactory` via `mcp serve` (simplest)
52-
- Multi-session: use `BuildMcpServerOptions` + one `McpServer.Create` per connection
196+
This is often the simplest fallback for editor integrations or agent hosts that don't implement native roots.
53197

54-
## Scenario B: MCP-over-HTTP (Streamable HTTP)
198+
## Dynamic tool compatibility shim
55199

56-
The MCP spec defines a native HTTP transport: POST for client→server messages, GET/SSE for server→client streaming, with session management. This requires an HTTP host (typically ASP.NET Core) rather than a CLI command.
200+
Some MCP clients receive `notifications/tools/list_changed` but do not refresh their tool list correctly.
57201

58-
### How it works
59-
60-
`BuildMcpServerOptions()` constructs the full `McpServerOptions` (tools, resources, prompts, capabilities) from your Repl app's command graph — without starting a server. You pass these options to the MCP C# SDK's HTTP integration.
202+
If your app has a dynamic tool list, you can opt in to a compatibility shim:
61203

62204
```csharp
63-
var app = ReplApp.Create();
64-
app.Map("greet {name}", (string name) => $"Hello, {name}!");
65-
app.Map("status", () => "all systems go").ReadOnly();
205+
using Repl.Mcp;
66206

67-
// Build MCP options from the command graph.
68-
var mcpOptions = app.Core.BuildMcpServerOptions(configure: o =>
207+
app.UseMcpServer(o =>
69208
{
70-
o.ServerName = "MyApi";
71-
o.ResourceUriScheme = "myapi";
209+
o.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim;
72210
});
211+
```
73212

74-
// Use with McpServer.Create for a custom HTTP handler...
75-
var server = McpServer.Create(httpTransport, mcpOptions);
213+
When enabled:
76214

77-
// ...or pass the collections to ASP.NET Core's MapMcp.
78-
```
215+
1. The first `tools/list` returns only `discover_tools` and `call_tool`
216+
2. The server emits `notifications/tools/list_changed`
217+
3. Later `tools/list` calls return the real tool set
218+
219+
This lets limited clients continue operating:
220+
- `discover_tools` returns the current real tools and schemas
221+
- `call_tool` invokes a real tool by name and arguments
222+
223+
Use this only when you need it.
224+
225+
Good candidates:
226+
- Tools appear after authentication
227+
- Tools depend on roots or soft roots
228+
- Tools vary by session or runtime context
229+
230+
Avoid it when:
231+
- Your tool list is static
232+
- Your client already handles `list_changed` correctly
233+
234+
## Choosing the right fallback
79235

80-
### Multi-session
236+
| Problem | Recommended approach |
237+
|---|---|
238+
| Client supports roots and refreshes tools correctly | Use the default MCP setup |
239+
| Client does not support roots | Add a soft-roots initialization tool |
240+
| Client supports tools but misses dynamic refreshes | Enable `DiscoverAndCallShim` |
241+
| Client has both issues | Use soft roots and, if needed, the dynamic tool shim |
81242

82-
Each HTTP request creates an isolated MCP session. This uses the same mechanism as Repl's hosted sessions:
243+
## Troubleshooting patterns
83244

84-
- `ReplSessionIO.SetSession()` creates an `AsyncLocal` scope per request
85-
- Each session has its own output writer, input reader, and session ID
86-
- Tool invocations are fully isolated — concurrent requests don't interfere
245+
### The agent doesn't see tools that should appear later
87246

88-
This is identical to how the framework handles concurrent tool calls in stdio mode (via `McpToolAdapter.ExecuteThroughPipelineAsync`).
247+
Check:
248+
- Your app calls `InvalidateRouting()` when session-driven state changes
249+
- The client actually refreshes after `list_changed`
250+
- `DynamicToolCompatibility` is enabled if the client is weak on dynamic discovery
89251

90-
### When to use
252+
If needed, see [mcp-server.md](mcp-server.md#troubleshooting) for the quick checklist and [mcp-internals.md](mcp-internals.md) for the behavior details.
91253

92-
- You're building a web API that also exposes MCP endpoints
93-
- You need multiple concurrent MCP sessions (agents connecting via HTTP)
94-
- You want to integrate with the ASP.NET Core pipeline (auth, middleware, etc.)
254+
### The agent doesn't know which workspace to use
95255

96-
## Configuration reference
256+
Check:
257+
- Whether the client supports native roots
258+
- Whether a roots-aware tool can inspect `IMcpClientRoots`
259+
- Whether you need a soft-roots init tool
97260

98-
| Option | Default | Description |
99-
|--------|---------|-------------|
100-
| `TransportFactory` | `null` (stdio) | Custom transport factory for Scenario A |
101-
| `ResourceUriScheme` | `"repl"` | URI scheme for MCP resources (`{scheme}://path`) |
102-
| `ServerName` | Assembly product name | Server name in MCP `initialize` response |
103-
| `ServerVersion` | `"1.0.0"` | Server version in MCP `initialize` response |
261+
### My module predicate depends on roots but never activates
104262

105-
See [mcp-server.md](mcp-server.md) for the full configuration reference.
263+
Check:
264+
- Whether the client actually advertises roots support
265+
- Whether you need `await roots.GetAsync(...)` in a handler rather than only a predicate
266+
- Whether soft roots are a better fit for that client

0 commit comments

Comments
 (0)