|
1 | | -# MCP Advanced: Custom Transports & HTTP Integration |
| 1 | +# MCP Advanced: Dynamic Tools, Roots, and Session-Aware Patterns |
2 | 2 |
|
3 | | -This guide covers two advanced integration scenarios beyond the default stdio transport. |
| 3 | +This guide covers advanced MCP usage patterns for Repl apps: |
4 | 4 |
|
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 |
6 | 9 |
|
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). |
8 | 15 |
|
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 |
10 | 17 |
|
11 | | -### How it works |
| 18 | +Most Repl MCP servers don't need any of this. |
12 | 19 |
|
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`. |
14 | 38 |
|
15 | 39 | ```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) => |
19 | 43 | { |
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(); |
25 | 49 | ``` |
26 | 50 |
|
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 | |
28 | 60 |
|
29 | | -### Multi-session (accept N connections) |
| 61 | +## Session-aware routing |
30 | 62 |
|
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. |
32 | 66 |
|
33 | 67 | ```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. |
35 | 84 |
|
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 |
38 | 177 | { |
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 | + } |
43 | 189 | } |
44 | 190 | ``` |
45 | 191 |
|
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: |
47 | 193 |
|
48 | | -### When to use |
| 194 | +> If `workspace_init` is available, call it first with the working directory you should operate in. |
49 | 195 |
|
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. |
53 | 197 |
|
54 | | -## Scenario B: MCP-over-HTTP (Streamable HTTP) |
| 198 | +## Dynamic tool compatibility shim |
55 | 199 |
|
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. |
57 | 201 |
|
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: |
61 | 203 |
|
62 | 204 | ```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; |
66 | 206 |
|
67 | | -// Build MCP options from the command graph. |
68 | | -var mcpOptions = app.Core.BuildMcpServerOptions(configure: o => |
| 207 | +app.UseMcpServer(o => |
69 | 208 | { |
70 | | - o.ServerName = "MyApi"; |
71 | | - o.ResourceUriScheme = "myapi"; |
| 209 | + o.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim; |
72 | 210 | }); |
| 211 | +``` |
73 | 212 |
|
74 | | -// Use with McpServer.Create for a custom HTTP handler... |
75 | | -var server = McpServer.Create(httpTransport, mcpOptions); |
| 213 | +When enabled: |
76 | 214 |
|
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 |
79 | 235 |
|
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 | |
81 | 242 |
|
82 | | -Each HTTP request creates an isolated MCP session. This uses the same mechanism as Repl's hosted sessions: |
| 243 | +## Troubleshooting patterns |
83 | 244 |
|
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 |
87 | 246 |
|
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 |
89 | 251 |
|
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. |
91 | 253 |
|
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 |
95 | 255 |
|
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 |
97 | 260 |
|
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 |
104 | 262 |
|
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