Skip to content

Commit 3a44274

Browse files
halter73Copilot
andcommitted
Register resource-subscription handlers only on the stateful server
Addresses Tarek's review nit on #1672. The stateless server at "/stateless" shared the stateful server's subscriptions dictionary, which he flagged as a smell. The deeper issue: registering WithSubscribeToResourcesHandler / WithUnsubscribeFromResourcesHandler unconditionally set Capabilities.Resources.Subscribe = true, so the stateless server advertised resources.subscribe in its initialize result and then rejected every resources/subscribe call with -32603 (InternalError) via the null-SessionId guard -- the "server bug" error code for a capability it deliberately can't honor. Resource subscriptions are meaningless in the stateless lifecycle anyway: there is no stable SessionId to key the subscription table and no persistent SSE stream to deliver notifications/resources/updated. So gate the two handlers behind `if (!stateless)`. The stateless server no longer advertises resources.subscribe (an actual subscribe now gets the SDK's standard capability rejection), and the subscriptions dictionary is scoped to the stateful branch that is its only user -- answering "does the stateless server need a dictionary?" with a plain no. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 541e92f commit 3a44274

1 file changed

Lines changed: 48 additions & 41 deletions

File tree

  • tests/ModelContextProtocol.ConformanceServer

tests/ModelContextProtocol.ConformanceServer/Program.cs

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,16 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
2121
builder.Logging.AddProvider(loggerProvider);
2222
}
2323

24-
// Dictionary of session IDs to a set of resource URIs they are subscribed to
25-
// The value is a ConcurrentDictionary used as a thread-safe HashSet
26-
// because .NET does not have a built-in concurrent HashSet
27-
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions = new();
28-
2924
// Configure the default, stateful MCP server (served at "/").
30-
ConfigureConformanceMcpServer(builder.Services, subscriptions, stateless: false);
25+
ConfigureConformanceMcpServer(builder.Services, stateless: false);
3126

3227
var app = builder.Build();
3328

3429
// Also expose a stateless MCP server at "/stateless" so a single conformance server can
3530
// serve both the legacy stateful lifecycle (at "/") and the SEP-2575 stateless lifecycle
3631
// (at "/stateless", which the 2026-07-28 "caching" (SEP-2549) and MRTR (SEP-2322)
3732
// scenarios require) from one Kestrel port.
38-
HandleStatelessMcp(app, subscriptions);
33+
HandleStatelessMcp(app);
3934

4035
app.MapMcp();
4136

@@ -46,14 +41,14 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
4641

4742
// Registers the conformance MCP server (tools, prompts, resources, filters, and handlers)
4843
// into the given service collection. Shared by the stateful ("/") and stateless ("/stateless")
49-
// servers so both expose identical behavior and differ only in their lifecycle.
44+
// servers, which expose identical behavior except that only the stateful server registers the
45+
// resource-subscription handlers (see below).
5046
private static void ConfigureConformanceMcpServer(
5147
IServiceCollection services,
52-
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions,
5348
bool stateless)
5449
{
5550
services.AddDistributedMemoryCache();
56-
services
51+
var mcpServerBuilder = services
5752
.AddMcpServer()
5853
.WithHttpTransport(options => options.Stateless = stateless)
5954
.WithDistributedCacheEventStreamStore()
@@ -115,33 +110,6 @@ private static void ConfigureConformanceMcpServer(
115110
.WithPrompts<ConformancePrompts>()
116111
.WithPrompts<IncompleteResultPrompts>()
117112
.WithResources<ConformanceResources>()
118-
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
119-
{
120-
if (ctx.Server.SessionId == null)
121-
{
122-
throw new McpException("Cannot add subscription for server with null SessionId");
123-
}
124-
if (ctx.Params.Uri is { } uri)
125-
{
126-
var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new());
127-
sessionSubscriptions.TryAdd(uri, 0);
128-
}
129-
130-
return new EmptyResult();
131-
})
132-
.WithUnsubscribeFromResourcesHandler(async (ctx, ct) =>
133-
{
134-
if (ctx.Server.SessionId == null)
135-
{
136-
throw new McpException("Cannot remove subscription for server with null SessionId");
137-
}
138-
if (ctx.Params.Uri is { } uri)
139-
{
140-
subscriptions[ctx.Server.SessionId].TryRemove(uri, out _);
141-
}
142-
143-
return new EmptyResult();
144-
})
145113
.WithCompleteHandler(async (ctx, ct) =>
146114
{
147115
// Basic completion support - returns empty array for conformance
@@ -169,15 +137,54 @@ private static void ConfigureConformanceMcpServer(
169137

170138
return new EmptyResult();
171139
});
140+
141+
// Resource subscriptions require a stable SessionId to key the subscription table and a
142+
// persistent SSE stream to deliver notifications/resources/updated, neither of which
143+
// exists in the stateless lifecycle. Only the stateful server registers these handlers,
144+
// so only it advertises the resources.subscribe capability.
145+
if (!stateless)
146+
{
147+
// Dictionary of session IDs to a set of resource URIs they are subscribed to. The
148+
// value is a ConcurrentDictionary used as a thread-safe HashSet because .NET does not
149+
// have a built-in concurrent HashSet.
150+
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions = new();
151+
152+
mcpServerBuilder
153+
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
154+
{
155+
if (ctx.Server.SessionId == null)
156+
{
157+
throw new McpException("Cannot add subscription for server with null SessionId");
158+
}
159+
if (ctx.Params.Uri is { } uri)
160+
{
161+
var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new());
162+
sessionSubscriptions.TryAdd(uri, 0);
163+
}
164+
165+
return new EmptyResult();
166+
})
167+
.WithUnsubscribeFromResourcesHandler(async (ctx, ct) =>
168+
{
169+
if (ctx.Server.SessionId == null)
170+
{
171+
throw new McpException("Cannot remove subscription for server with null SessionId");
172+
}
173+
if (ctx.Params.Uri is { } uri)
174+
{
175+
subscriptions[ctx.Server.SessionId].TryRemove(uri, out _);
176+
}
177+
178+
return new EmptyResult();
179+
});
180+
}
172181
}
173182

174183
// Maps a second MCP server, configured for the stateless lifecycle, at "/stateless". It is
175184
// built in its own ServiceCollection so its DI (and HttpServerTransportOptions) stays isolated
176185
// from the stateful server registered on the main host. Adapted from
177186
// ModelContextProtocol.TestSseServer.Program.HandleStatelessMcp.
178-
private static void HandleStatelessMcp(
179-
WebApplication app,
180-
ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> subscriptions)
187+
private static void HandleStatelessMcp(WebApplication app)
181188
{
182189
var services = new ServiceCollection();
183190
services.AddLogging();
@@ -186,7 +193,7 @@ private static void HandleStatelessMcp(
186193
services.AddSingleton(app.Services.GetRequiredService<DiagnosticListener>());
187194
services.AddRoutingCore();
188195

189-
ConfigureConformanceMcpServer(services, subscriptions, stateless: true);
196+
ConfigureConformanceMcpServer(services, stateless: true);
190197

191198
var statelessApp = new ApplicationBuilder(services.BuildServiceProvider());
192199
statelessApp.UseRouting();

0 commit comments

Comments
 (0)