With MCP C# per-request client capabilities, the 2026-07-28
protocol changes where a server must read what the caller supports. Modern stateless requests carry their own clientCapabilities
inside _meta
; there is no initialization handshake whose values can be retained for the connection.
That creates a small but important C# trap. Inside a stateless HTTP handler, request.Server.ClientCapabilities
is intentionally null
. The authoritative value lives on the current JSON-RPC request. If I cache a previous value, or treat the server property as the modern source, I can make the wrong decision for the next call.
The MCP 2026-07-28 release removed protocol-level sessions and the initialize
exchange from the modern path. Every request is self-contained, which lets separate server instances handle calls without sticky routing or a shared MCP session store.
The request now carries reserved metadata such as:
{
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "report-client",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"com.example/report-export": {}
}
}
}
}
The key word is request. A server must not infer capabilities from an earlier message. Two calls reaching the same process may legitimately declare different extension support, and concurrent handlers must stay isolated.
The older handshake model encouraged a connection-level mental model: negotiate once, then consult the negotiated server object later. That assumption does not survive a sessionless request that can land on any instance. There is no durable "current client" whose feature set safely belongs in a singleton, static field, or process-wide cache. Even when one client normally sends the same declaration each time, the protocol boundary still has to treat the envelope it received as the source for that call.
This also matters during staged migrations. A load test, proxy, or compatibility client can mix modern requests with different extension maps in one server process. Code that appears correct with one client may fail only under overlap, which is why I prefer a concurrency regression instead of a single serialized example.
I used the stable ModelContextProtocol.AspNetCore 2.2.0 package for the sample. This is released functionality, not a preview API.
The C# SDK exposes the parsed value through JsonRpcMessageContext.ClientCapabilities
. A tool can reach it from its injected RequestContext<CallToolRequestParams>
:
public async Task<string> InspectClientCapabilityAsync(
string requestName,
RequestContext<CallToolRequestParams> request,
CapabilityBarrier barrier,
CancellationToken cancellationToken)
{
await barrier.WaitForBothAsync(cancellationToken);
var capabilities = request.JsonRpcRequest.Context?.ClientCapabilities;
var enabled = capabilities?.Extensions?
.ContainsKey("com.example/report-export") is true;
var serverValue = request.Server.ClientCapabilities is null
? "null"
: "set";
return $"{requestName}:request={(enabled ? "enabled" : "disabled")},server={serverValue}";
}
The official JsonRpcMessageContext reference calls the current request authoritative. That is the contract I want the code to make visible.
Keeping the lookup beside the tool decision makes the null
cases easier to reason about. A missing request context means I do not have an affirmative declaration. An empty extensions dictionary means the client sent a valid capability object without this extension. Neither should silently fall back to whatever the previous handler observed.
Notice that I am checking an extension declaration, not granting access. Client capabilities are self-reported protocol features. They are useful for deciding whether a response shape or interaction is supported, but they are not authentication, authorization, or trusted identity. A protected report export still needs validated claims and application policy.
A sequential happy-path test can miss cross-request state. The complete sample starts an ASP.NET Core TestServer
, so it exercises the real Streamable HTTP endpoint without opening a port.
It sends two tools/call
requests concurrently. One advertises com.example/report-export
; the other sends an empty extensions map. A one-shot barrier s both tool handlers until they have arrived, then lets each read its request context. The verifier expects:
enabled-request:request=enabled,server=null
disabled-request:request=disabled,server=null
That assertion checks two boundaries at once: each call retains its own capability value, and the stateless server property is not mistaken for the request metadata source.
The barrier is deliberate. Both handlers arrive before either reads the capability, so the test does not pass merely because the requests happened to run one after another. If application code moved the declaration into shared mutable state, this arrangement would expose the last-writer-wins mistake. The five-second guard turns a broken dispatch path into a failure instead of leaving the test process blocked.
The verifier also sends a modern request that omits io.modelcontextprotocol/clientCapabilities
. The SDK rejects it before the tool runs with HTTP 400 and JSON-RPC code -32602
. That is different from a well-formed request that declares capabilities but lacks a feature a particular operation requires.
Run the checks with:
dotnet restore
dotnet format --verify-no-changes --no-restore
dotnet build --configuration Release --no-restore
dotnet run --configuration Release --no-build
dotnet list package --vulnerable --include-transitive
The pull request and validation record are available here. The sample uses fixed JSON, an in-memory server, and no MCP host, credential, model call, database, or runtime network request.
This pattern targets the modern stateless HTTP path. Initialize-era clients establish capabilities during the legacy handshake, so migration code that supports both eras should follow the SDK's version-specific behavior rather than manually copying _meta
into a global cache.
The sample also checks one custom extension, not every built-in capability or extension schema. In production I would centralize the extension name and validate any settings object before consuming it. I would still pass the current request into that policy rather than storing a mutable capability snapshot. If several tools need the same decision, a request-scoped service is a better home than a singleton.
I also would not add a capability check to ordinary tools that never depend on an optional client feature. More gates create more failure modes. Read the metadata where a response or interaction genuinely requires it, keep the check request-scoped, and test the missing declaration separately from an unauthorized caller.
How are you testing request-scoped metadata before moving an MCP server to the 2026-07-28
path?
Cheers!