{"slug": "mcp-c-per-request-client-capabilities-read-the-request-not-the-server", "title": "MCP C# Per-Request Client Capabilities: Read the Request, Not the Server", "summary": "A developer highlights a C# pitfall in the MCP 2026-07-28 protocol, where per-request client capabilities replace the old initialization handshake. The developer explains that servers must read capabilities from the current JSON-RPC request's _meta field, not from server properties or cached values, and demonstrates using the ModelContextProtocol.AspNetCore 2.2.0 package to access them via RequestContext.", "body_md": "With **MCP C# per-request client capabilities**, the `2026-07-28`\n\nprotocol changes where a server must read what the caller supports. Modern stateless requests carry their own `clientCapabilities`\n\ninside `_meta`\n\n; there is no initialization handshake whose values can be retained for the connection.\n\nThat creates a small but important C# trap. Inside a stateless HTTP handler, `request.Server.ClientCapabilities`\n\nis intentionally `null`\n\n. 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.\n\nThe [MCP 2026-07-28 release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) removed protocol-level sessions and the `initialize`\n\nexchange 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.\n\nThe request now carries reserved metadata such as:\n\n```\n{\n  \"_meta\": {\n    \"io.modelcontextprotocol/protocolVersion\": \"2026-07-28\",\n    \"io.modelcontextprotocol/clientInfo\": {\n      \"name\": \"report-client\",\n      \"version\": \"1.0.0\"\n    },\n    \"io.modelcontextprotocol/clientCapabilities\": {\n      \"extensions\": {\n        \"com.example/report-export\": {}\n      }\n    }\n  }\n}\n```\n\nThe 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.\n\nThe 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.\n\nThis 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.\n\nI used the stable [ ModelContextProtocol.AspNetCore 2.2.0 package](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore/2.2.0) for the sample. This is released functionality, not a preview API.\n\nThe C# SDK exposes the parsed value through `JsonRpcMessageContext.ClientCapabilities`\n\n. A tool can reach it from its injected `RequestContext<CallToolRequestParams>`\n\n:\n\n```\npublic async Task<string> InspectClientCapabilityAsync(\n    string requestName,\n    RequestContext<CallToolRequestParams> request,\n    CapabilityBarrier barrier,\n    CancellationToken cancellationToken)\n{\n    await barrier.WaitForBothAsync(cancellationToken);\n\n    var capabilities = request.JsonRpcRequest.Context?.ClientCapabilities;\n    var enabled = capabilities?.Extensions?\n        .ContainsKey(\"com.example/report-export\") is true;\n\n    var serverValue = request.Server.ClientCapabilities is null\n        ? \"null\"\n        : \"set\";\n\n    return $\"{requestName}:request={(enabled ? \"enabled\" : \"disabled\")},server={serverValue}\";\n}\n```\n\nThe [official JsonRpcMessageContext reference](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.Protocol.JsonRpcMessageContext.html) calls the current request authoritative. That is the contract I want the code to make visible.\n\nKeeping the lookup beside the tool decision makes the `null`\n\ncases 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.\n\nNotice 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.\n\nA sequential happy-path test can miss cross-request state. The [complete sample](https://github.com/ssukhpinder/dev-to-code-samples/tree/main/090-mcp-request-capabilities) starts an ASP.NET Core `TestServer`\n\n, so it exercises the real Streamable HTTP endpoint without opening a port.\n\nIt sends two `tools/call`\n\nrequests concurrently. One advertises `com.example/report-export`\n\n; the other sends an empty extensions map. A one-shot barrier pauses both tool handlers until they have arrived, then lets each read its request context. The verifier expects:\n\n```\nenabled-request:request=enabled,server=null\ndisabled-request:request=disabled,server=null\n```\n\nThat 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.\n\nThe 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.\n\nThe verifier also sends a modern request that omits `io.modelcontextprotocol/clientCapabilities`\n\n. The SDK rejects it before the tool runs with HTTP 400 and JSON-RPC code `-32602`\n\n. That is different from a well-formed request that declares capabilities but lacks a feature a particular operation requires.\n\nRun the checks with:\n\n```\ndotnet restore\ndotnet format --verify-no-changes --no-restore\ndotnet build --configuration Release --no-restore\ndotnet run --configuration Release --no-build\ndotnet list package --vulnerable --include-transitive\n```\n\nThe pull request and validation record are available [here](https://github.com/ssukhpinder/dev-to-code-samples/pull/80). The sample uses fixed JSON, an in-memory server, and no MCP host, credential, model call, database, or runtime network request.\n\nThis 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`\n\ninto a global cache.\n\nThe 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.\n\nI 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.\n\nHow are you testing request-scoped metadata before moving an MCP server to the `2026-07-28`\n\npath?\n\nCheers!", "url": "https://wpnews.pro/news/mcp-c-per-request-client-capabilities-read-the-request-not-the-server", "canonical_source": "https://dev.to/ssukhpinder/mcp-c-per-request-client-capabilities-read-the-request-not-the-server-566m", "published_at": "2026-08-29 15:07:15+00:00", "updated_at": "2026-08-29 15:19:01.543590+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["ModelContextProtocol.AspNetCore", "JsonRpcMessageContext", "MCP"], "alternates": {"html": "https://wpnews.pro/news/mcp-c-per-request-client-capabilities-read-the-request-not-the-server", "markdown": "https://wpnews.pro/news/mcp-c-per-request-client-capabilities-read-the-request-not-the-server.md", "text": "https://wpnews.pro/news/mcp-c-per-request-client-capabilities-read-the-request-not-the-server.txt", "jsonld": "https://wpnews.pro/news/mcp-c-per-request-client-capabilities-read-the-request-not-the-server.jsonld"}}