You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
JSON property-name casing is inconsistent between JSON-RPC envelope and request params #1897
The SDK applies different JSON property-name casing rules at two levels of the same request:
JSON-RPC envelope properties are matched case-sensitively by JsonRpcMessage.Converter (jsonrpc, id, method, params).
Method-specific params are deserialized with McpJsonUtilities.DefaultOptions / JsonSerializerDefaults.Web, so DTO property names are accepted case-insensitively.
For example, this tools/call request is accepted and dispatches the tool because Name binds to CallToolRequestParams.Name:
Changing method to Method or params to Params is rejected by the envelope converter.
Why this matters
Middleware, gateways, and authorization filters that inspect the JSON-RPC request before SDK dispatch can use exact JSON property lookup based on the envelope converter. They may then fail to recognize a request that the SDK later accepts and dispatches when a nested DTO property uses alternate casing.
We reproduced this with pre-transport tool authorization middleware: exact lookup of params.name missed params.Name, while SDK 2.1.0 accepted and invoked the protected tool.
Expected behavior
Use one documented casing rule consistently across the JSON-RPC envelope and method-specific params. Strict protocol property matching throughout would avoid ambiguous pre-dispatch behavior; alternatively, if case-insensitive params are intentional, document that boundary explicitly so middleware can match SDK semantics.
A regression test covering tools/call with lowercase envelope fields and alternate-cased nested Name would pin the intended behavior.
Verified this also occurs in the previous SDK release used by our application, v1.3.0 (tag commit 2ce15f4c8af7d1d8c3f3bc141a7cd0830d3dddfb), so it was not introduced by our upgrade from 1.3.0 to 2.1.0.
In v1.3.0:
JsonRpcMessage.Converter uses exact switch cases for jsonrpc, id, method, and params.
McpJsonUtilities.JsonContext uses JsonSerializerDefaults.Web and includes CallToolRequestParams.
RequestHandlers.Set deserializes request.Params with that generated JsonTypeInfo<CallToolRequestParams>.
Therefore lowercase envelope fields with nested Name already dispatch in 1.3.0, while alternate-cased envelope fields do not.
Thanks, @nagavitalp. We should be consistent for sure. Protocol-defined properties should consistently be treated with case sensitivity, while we can be case-insensitive for anything not part of the protocol.
Hi, I'd like to help with this. Following the maintainer's guidance, I would make protocol-defined properties case-sensitive while preserving the existing handling of tool arguments and other non-protocol data, with regression tests for both boundaries.
I'd like to work on this. Here's the approach I have in mind, based on @jeffhandley's comment that protocol-defined properties should be case-sensitive while anything outside the protocol can stay lenient. Please let me know if it matches what you want before I open a PR.
The two layers already go through different contracts:
The envelope converter (JsonRpcMessage.Converter) matches jsonrpc / id / method / params / result / error exactly and keeps params and result as JsonNode.
The typed deserialization of those nodes uses the internal source-generated context (McpJsonUtilities.JsonContext.Default.CallToolRequestParams and the other ~200 JsonContext.Default.* type infos passed to RequestHandlers.Set, the client request methods, and so on). That context is declared with JsonSerializerDefaults.Web, which turns on PropertyNameCaseInsensitive. This is where params.Name gets bound.
User-defined types (tool, prompt and resource parameters, McpClientTool arguments, sampling) go through McpJsonUtilities.DefaultOptions or the caller's own JsonSerializerOptions, not through the context's own options.
So the proposed change is:
Add PropertyNameCaseInsensitive = false to the [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, ...)] on the internal JsonContext. Protocol DTOs and their nested types are then matched exactly, the same way as the envelope, for requests, results and notifications on both the client and the server.
In CreateDefaultOptions, set options.PropertyNameCaseInsensitive = true after copying the context's options. The public DefaultOptions keeps its documented Web behavior, so user types and tool argument binding are unaffected. The "keep in sync" comment and the <remarks> would note this one intentional difference.
Tests: tools/call with "Name" is rejected with an error response and the tool is not invoked, the camelCase form still works, the same check for a result type on the client side (for example CallToolResult with "Content"), and a test that DefaultOptions still binds a user DTO case-insensitively.
Two things I'd like your view on:
After this change, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams)) would still be case-insensitive, because PropertyNameCaseInsensitive is an options-level setting and can't be set per type. The SDK's own dispatch wouldn't use that path, but code that deserializes protocol types with DefaultOptions itself would. Is that acceptable as long as it's documented, or would you rather make DefaultOptions strict as well and accept the behavior change for user types?
This is observable for peers that send non-camelCase property names today. The TypeScript and Python SDKs already match these names exactly (zod / pydantic), so I'd expect little real traffic to depend on it, but it probably deserves a line in the release notes.
@FenjuFu This looks great. I will assign to you. Ensuring test coverage of the strictness will be valuable as you have captured here. @tarekgh can be tagged for review on the PR.
Note to myself for release notes preparation: We will need to highlight this as a subtle breaking change for conformance reasons, and we should bump the minor version with this update rather than treating it as a patch version.
@halter73 had the suggestion of checking what other SDKs do, and that research supports this change too.
MCP SDK JSON property casing: private review
Snapshot: October 14, 2026. The live SDK listing identifies C#, TypeScript, Python, Go, Rust, and Ruby as Tier 1. All five C# peers are covered below. Tier guidance requires full applicable conformance, but is not itself evidence of casing behavior.
Bottom line: Making C# protocol DTO lookup case-sensitive aligns with every peer's ordinary tools/call dispatch: none maps params.Name to protocol params.name. However, peers do not uniformly reject alternate-cased keys, and some retain important lenient paths. Describe the change as exact protocol-field matching, not universal rejection of noncanonical JSON.
1. Protocol fields — proposed strict matching
🟢 Exact matching · 🔴 Alternate casing binds · 🟡 Default/raw-data exception. These describe matching. Headers are fragments of otherwise valid messages; canonical name works in every SDK.
¹ No binding is not universal rejection. Missing required fields cause deserialization/validation failure in proposed C# (JsonException), TypeScript and Python. Go leaves name="" or content unset without required-field validation. Rust can fall back to custom messages/results; its default custom-request handler returns MethodNotFound. Ruby reaches tool lookup without a name. None dispatches the named tool from Name alone.
² Order matters in current C#. Distinct name and Name both bind: last value wins. Reversing their order yields "a". Exact matching always selects canonical "a".
³ Defaults remain valid. Proposed C# ignores Content and keeps content=[]. TypeScript does the same for protocol 2025-11-25, but rejects the missing canonical field for 2026-07-28. Case sensitivity alone does not add required-field validation.
⁴ Raw data, not canonical binding. Ruby retains Content with text "B" in a raw hash; it does not map it to content.
2. User DTO fields — retained relaxed matching
Illustration: the application deserializes the arguments object into a user DTO whose Name property maps to JSON name, using public McpJsonUtilities.DefaultOptions. This is not a protocol DTO or a guarantee about every tool's argument binder. Here 🟢 means compatibility retained, including intentionally case-insensitive binding.
SDK (Version)
{ arguments: { name: "a" } }
{ arguments: { Name: "B" } }
{ arguments: { name: "a", Name: "B" } }
C# (2.2.0)
🟢 "a" bound
🟢 "B" bound
🟢 "B" bound⁵
C# (proposed)
🟢 "a" bound
🟢 "B" bound
🟢 "B" bound⁵
⁵ Last value wins for this user DTO. Both spellings bind to the same property; reversing their order yields "a". This behavior is intentionally retained, unlike protocol fields.
The outer arguments field remains strict under the proposal; its contents are user data. Raw dictionaries preserve name and Name as distinct keys rather than folding them. Peer SDKs do not promise C#-style relaxed user binding: schema/handler rules decide acceptance, Go's typed argument binding is exact, and Ruby symbolizes keys without lowercasing.
Decision: The proposal aligns ordinary C# tool dispatch with peers. Require no alternate-key binding, not blanket rejection; test both key orders, required/default fields, and the separate user-data boundary.
Material qualifications
Requests, results, and notifications are not equally strict. C#'s name is required, but tool-result content defaults to an empty list: the proposed flag alone would not reject Content-only results. TypeScript's loose results preserve extras; its 2025 adapter adds content: [] rather than renaming Content, while 2026 requires content. Python rejects Content-only results and ignores optional IsError, retaining false; its notification/result parsing also uses alias-only validation.
Main versus stable: All five pinned main revisions were compared. TypeScript/Python have no relevant casing change (their v2 rewrites are already stable). Go retains the mixed decoding paths. Rust main adds conflicting-envelope-field checks, still exact-key based. Ruby main adds incoming JSON-RPC version validation; this is unreleased and does not introduce universal nested-result validation.
User data and C# compatibility: Arbitrary argument/structured-content keys are not protocol field names; peers preserve their spelling, leaving schema/handler validation to applications. They do not promise case-insensitive user binding (Go's typed argument decoder is also exact). Keeping C# public DefaultOptions lenient therefore preserves C# compatibility, rather than copying a peer guarantee. Manual protocol deserialization through those public options remains an explicitly documented exception; middleware must match the actual dispatch contract.
Evidence limits: Independently authored probes ran against the actual pinned TypeScript/Python schemas for both protocol eras; outputs are retained privately alongside this document. Go/Rust/Ruby findings are source/test-backed, not locally executed SDK suites. This is a targeted boundary review, not proof that every protocol model/transport rejects every malformed field.
Activity
nagavitalp commentedon Sep 28, 2026
Verified this also occurs in the previous SDK release used by our application,
v1.3.0(tag commit2ce15f4c8af7d1d8c3f3bc141a7cd0830d3dddfb), so it was not introduced by our upgrade from 1.3.0 to 2.1.0.In v1.3.0:
JsonRpcMessage.Converteruses exactswitchcases forjsonrpc,id,method, andparams.McpJsonUtilities.JsonContextusesJsonSerializerDefaults.Weband includesCallToolRequestParams.RequestHandlers.Setdeserializesrequest.Paramswith that generatedJsonTypeInfo<CallToolRequestParams>.Therefore lowercase envelope fields with nested
Namealready dispatch in 1.3.0, while alternate-cased envelope fields do not.jeffhandley commentedon Oct 7, 2026
Thanks, @nagavitalp. We should be consistent for sure. Protocol-defined properties should consistently be treated with case sensitivity, while we can be case-insensitive for anything not part of the protocol.
ZYZ666-RGB commentedon Oct 8, 2026
Hi, I'd like to help with this. Following the maintainer's guidance, I would make protocol-defined properties case-sensitive while preserving the existing handling of tool arguments and other non-protocol data, with regression tests for both boundaries.
Is this available for me to take on?
FenjuFu commentedon Oct 8, 2026
I'd like to work on this. Here's the approach I have in mind, based on @jeffhandley's comment that protocol-defined properties should be case-sensitive while anything outside the protocol can stay lenient. Please let me know if it matches what you want before I open a PR.
The two layers already go through different contracts:
JsonRpcMessage.Converter) matchesjsonrpc/id/method/params/result/errorexactly and keepsparamsandresultasJsonNode.McpJsonUtilities.JsonContext.Default.CallToolRequestParamsand the other ~200JsonContext.Default.*type infos passed toRequestHandlers.Set, the client request methods, and so on). That context is declared withJsonSerializerDefaults.Web, which turns onPropertyNameCaseInsensitive. This is whereparams.Namegets bound.McpClientToolarguments, sampling) go throughMcpJsonUtilities.DefaultOptionsor the caller's ownJsonSerializerOptions, not through the context's own options.So the proposed change is:
PropertyNameCaseInsensitive = falseto the[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, ...)]on the internalJsonContext. Protocol DTOs and their nested types are then matched exactly, the same way as the envelope, for requests, results and notifications on both the client and the server.CreateDefaultOptions, setoptions.PropertyNameCaseInsensitive = trueafter copying the context's options. The publicDefaultOptionskeeps its documented Web behavior, so user types and tool argument binding are unaffected. The "keep in sync" comment and the<remarks>would note this one intentional difference.tools/callwith"Name"is rejected with an error response and the tool is not invoked, the camelCase form still works, the same check for a result type on the client side (for exampleCallToolResultwith"Content"), and a test thatDefaultOptionsstill binds a user DTO case-insensitively.Two things I'd like your view on:
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))would still be case-insensitive, becausePropertyNameCaseInsensitiveis an options-level setting and can't be set per type. The SDK's own dispatch wouldn't use that path, but code that deserializes protocol types withDefaultOptionsitself would. Is that acceptable as long as it's documented, or would you rather makeDefaultOptionsstrict as well and accept the behavior change for user types?jeffhandley commentedon Oct 14, 2026
@FenjuFu This looks great. I will assign to you. Ensuring test coverage of the strictness will be valuable as you have captured here. @tarekgh can be tagged for review on the PR.
Note to myself for release notes preparation: We will need to highlight this as a subtle breaking change for conformance reasons, and we should bump the minor version with this update rather than treating it as a patch version.
jeffhandley commentedon Oct 14, 2026
@halter73 had the suggestion of checking what other SDKs do, and that research supports this change too.
MCP SDK JSON property casing: private review
Snapshot: October 14, 2026. The live SDK listing identifies C#, TypeScript, Python, Go, Rust, and Ruby as Tier 1. All five C# peers are covered below. Tier guidance requires full applicable conformance, but is not itself evidence of casing behavior.
Bottom line: Making C# protocol DTO lookup case-sensitive aligns with every peer's ordinary
tools/calldispatch: none mapsparams.Nameto protocolparams.name. However, peers do not uniformly reject alternate-cased keys, and some retain important lenient paths. Describe the change as exact protocol-field matching, not universal rejection of noncanonical JSON.1. Protocol fields — proposed strict matching
🟢 Exact matching · 🔴 Alternate casing binds · 🟡 Default/raw-data exception. These describe matching. Headers are fragments of otherwise valid messages; canonical
nameworks in every SDK.{ Method: "tools/call" }{ Name: "B" }{ name: "a", Name: "B" }{ Content: [{ type: "text", text: "B" }] }"B"bound"B"bound²"B"bound"a"bound[]default³"a"bound[]default / reject³"a"bound""default¹"a"bound"a"bound"a"bound"B"preserved⁴¹ No binding is not universal rejection. Missing required fields cause deserialization/validation failure in proposed C# (
JsonException), TypeScript and Python. Go leavesname=""orcontentunset without required-field validation. Rust can fall back to custom messages/results; its default custom-request handler returns MethodNotFound. Ruby reaches tool lookup without a name. None dispatches the named tool fromNamealone.² Order matters in current C#. Distinct
nameandNameboth bind: last value wins. Reversing their order yields"a". Exact matching always selects canonical"a".³ Defaults remain valid. Proposed C# ignores
Contentand keepscontent=[]. TypeScript does the same for protocol 2025-11-25, but rejects the missing canonical field for 2026-07-28. Case sensitivity alone does not add required-field validation.⁴ Raw data, not canonical binding. Ruby retains
Contentwith text"B"in a raw hash; it does not map it tocontent.2. User DTO fields — retained relaxed matching
Illustration: the application deserializes the
argumentsobject into a user DTO whoseNameproperty maps to JSONname, using publicMcpJsonUtilities.DefaultOptions. This is not a protocol DTO or a guarantee about every tool's argument binder. Here 🟢 means compatibility retained, including intentionally case-insensitive binding.{ arguments: { name: "a" } }{ arguments: { Name: "B" } }{ arguments: { name: "a", Name: "B" } }"a"bound"B"bound"B"bound⁵"a"bound"B"bound"B"bound⁵⁵ Last value wins for this user DTO. Both spellings bind to the same property; reversing their order yields
"a". This behavior is intentionally retained, unlike protocol fields.The outer
argumentsfield remains strict under the proposal; its contents are user data. Raw dictionaries preservenameandNameas distinct keys rather than folding them. Peer SDKs do not promise C#-style relaxed user binding: schema/handler rules decide acceptance, Go's typed argument binding is exact, and Ruby symbolizes keys without lowercasing.Decision: The proposal aligns ordinary C# tool dispatch with peers. Require no alternate-key binding, not blanket rejection; test both key orders, required/default fields, and the separate user-data boundary.
Material qualifications
Requests, results, and notifications are not equally strict. C#'s
nameis required, but tool-resultcontentdefaults to an empty list: the proposed flag alone would not rejectContent-only results. TypeScript's loose results preserve extras; its 2025 adapter addscontent: []rather than renamingContent, while 2026 requirescontent. Python rejectsContent-only results and ignores optionalIsError, retaining false; its notification/result parsing also uses alias-only validation.Go is a genuine consistency exception. Normal request/notification DTOs and custom
CallToolResultdecoding are case-sensitive, but ordinary result decoding uses standardjson.Unmarshal, and embedded input requests/responses also use it. For plain structs, the decoder actually falls back to folded-name matching: e.g.Toolscan populatetools; both spellings can overwrite in input order. This is not evidence thatNamedispatches a tool.Rust and Ruby permit unknown/raw data. Rust defaults missing tool-result
contentto an empty array when another known field exists; unknown keys do not become canonical fields. Custom request/result fallbacks preclude claiming parser-wide rejection. Ruby returns raw tool-response hashes, not universally validated nested result DTOs; stable's stdio client does not check incomingjsonrpc.Main versus stable: All five pinned main revisions were compared. TypeScript/Python have no relevant casing change (their v2 rewrites are already stable). Go retains the mixed decoding paths. Rust main adds conflicting-envelope-field checks, still exact-key based. Ruby main adds incoming JSON-RPC version validation; this is unreleased and does not introduce universal nested-result validation.
User data and C# compatibility: Arbitrary argument/structured-content keys are not protocol field names; peers preserve their spelling, leaving schema/handler validation to applications. They do not promise case-insensitive user binding (Go's typed argument decoder is also exact). Keeping C# public
DefaultOptionslenient therefore preserves C# compatibility, rather than copying a peer guarantee. Manual protocol deserialization through those public options remains an explicitly documented exception; middleware must match the actual dispatch contract.Evidence limits: Independently authored probes ran against the actual pinned TypeScript/Python schemas for both protocol eras; outputs are retained privately alongside this document. Go/Rust/Ruby findings are source/test-backed, not locally executed SDK suites. This is a targeted boundary review, not proof that every protocol model/transport rejects every malformed field.
Match protocol property names case-sensitively