Skip to content

Match protocol property names case-sensitively - #1934

Open
FenjuFu wants to merge 2 commits into
modelcontextprotocol:mainfrom
FenjuFu:fix/protocol-property-casing
Open

FenjuFu wants to merge 2 commits into
modelcontextprotocol:mainfrom
FenjuFu:fix/protocol-property-casing

Conversation

@FenjuFu

@FenjuFu FenjuFu commented Oct 15, 2026 •

Copy link
Copy Markdown

Fixes #1897

The JSON-RPC envelope converter matches jsonrpc/id/method/params/result/error exactly, but the typed contents of params and result were deserialized with the internal source-generated JsonContext, which is declared with JsonSerializerDefaults.Web and therefore binds property names case-insensitively. A tools/call request with params.Name was dispatched as if it were params.name, even though middleware or proxies inspecting params.name would not recognize it. Some transports and the Tasks extension also deserialized protocol types through the case-insensitive DefaultOptions, so fields such as error.Code or a task-based CallToolResult.IsError were bound regardless of casing.

Changes

  • McpJsonUtilities.JsonContext now sets PropertyNameCaseInsensitive = false. Protocol DTOs (requests, results and notifications, on both client and server) only bind the property names defined by the specification, consistent with the envelope.
  • New public McpJsonUtilities.ProtocolOptions: a cached, read-only copy of DefaultOptions with case-sensitive property matching. It is built from the same source-generated context, so trimming and Native AOT behave as before. Consumers deserializing protocol messages themselves can use it.
  • The protocol readers that went through options now use ProtocolOptions: the stdio client and server transports (StreamClientSessionTransport, StreamServerTransport), the ASP.NET Core Streamable HTTP and SSE request reader (StreamableHttpHandler), and every protocol type in ModelContextProtocol.Extensions.Tasks (task-based CallToolResult, InputRequest, IDictionary<string, InputResponse>, JsonRpcErrorDetail). The HTTP client transports already used the case-sensitive context.
  • McpJsonUtilities.DefaultOptions explicitly keeps PropertyNameCaseInsensitive = true. It is also used for user-defined types (tool, prompt and resource parameters, McpClientTool arguments, SendRequestAsync<TParameters, TResult>), so binding of user data is unchanged. Its XML docs now point to ProtocolOptions and state that protocol types deserialized through DefaultOptions keep case-insensitive matching.

As discussed in the issue, this is exact protocol-field matching, not new validation of non-canonical JSON:

tools/call params Before After
{"Name":"echo"} dispatched to echo name missing, JSON-RPC error, handler not invoked
{"name":"echo","Name":"other"} last value wins (other) echo
{"Name":"other","name":"echo"} echo echo
{"name":"echo","Arguments":{...}} arguments bound arguments not bound (null)
{"name":"echo","arguments":{"text":"a","Text":"B"}} both keys kept both keys kept (user data)

Fields with defaults keep their defaults when only an alternately cased key is present; for example a CallToolResult with only Content still ends up with an empty content list rather than failing.

Not changed

  • McpApps.SerializerOptions (Apps extension) is public and derived from DefaultOptions. I left it as is; it can switch to ProtocolOptions if the _meta.ui types should follow the protocol rules too.
  • The OAuth metadata types live in the same source-generated context, so their parsing also becomes case-sensitive. Worth mentioning in the release notes together with the protocol change.

Tests

  • McpServerTests.Call_Tool_Request_Params_Are_Matched_Case_Sensitively sends raw tools/call requests through the server: Name only (rejected, handler not invoked), alternately cased Arguments (not bound), canonical, both key orders with name and Name, and alternately cased keys inside arguments (preserved as distinct entries).
  • McpJsonUtilitiesTests: ProtocolOptions is a read-only singleton with case-sensitive matching; for a JSON-RPC error with both code/message and Code/Message, and for a CallToolResult with Content/IsError, ProtocolOptions binds only the canonical names while DefaultOptions keeps the previous behavior. DefaultOptions_MatchesPropertyNamesCaseInsensitively still covers user types.
  • StdioServerTransportTests.ReadMessagesAsync_Should_Match_Error_Properties_Case_Sensitively reads such an error line through the stdio transport.
  • McpServerToolTests.BindsNestedArgumentPropertiesCaseInsensitively invokes a tool whose parameter is a nested user DTO, bound from LOCATION/CITY, to confirm user-data binding is unchanged.

The server and stdio transport tests fail without the change. Locally on net10.0 (Windows): ModelContextProtocol.Tests 2414 passed / 6 skipped, ModelContextProtocol.AspNetCore.Tests 636 passed / 31 skipped, 0 failed.

As noted in the issue, this is a subtle behavior change for peers that relied on alternate casing, so it probably belongs in a minor release.

cc @tarekgh for review

@tarekgh tarekgh left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix. This approach makes sense: match protocol-defined fields exactly while preserving the existing DefaultOptions behavior for user-defined data.

I checked TypeScript, Python, Go, Rust, and Ruby. None binds params.Name as the canonical tool name, and canonical name wins when both spellings are present, regardless of order. TypeScript and Python runtime probes confirmed this in both protocol eras. This supports the change here.

The important distinction is exact matching, not blanket rejection of noncanonical JSON. Peers differ in required-field validation, defaults, and raw/custom-result handling.

Looks good overall. A few non-blocking suggestions:

  • Add a client-side result test covering alternate-cased optional fields such as Content and IsError, and a nested user-DTO-through-tool test confirming that its existing binding behavior is preserved.
  • Track the remaining internal casing differences separately, particularly task-based CallToolResult deserialization and transport-level JsonRpcErrorDetail parsing. These do not bypass the tool-name fix.
  • Include the behavior change in the planned minor-release notes. The shared context also changes OAuth model parsing, so that compatibility impact should be mentioned.

CC @jeffhandley @halter73

/// </para>
/// <para>
/// Property names are matched case-insensitively, as with <see cref="JsonSerializerDefaults.Web"/>. The SDK itself
/// deserializes incoming protocol messages with case-sensitive contracts, so a protocol property such as

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The SDK itself deserializes incoming protocol messages with case-sensitive contracts" is broader than the implementation. Some internal readers still use the case-insensitive public options, including task-based tool-result readers and transport-level error-detail parsing.

Could we qualify this wording and explicitly document that protocol types deserialized through DefaultOptions retain case-insensitive matching? That would describe the boundary accurately without requiring those follow-ups to block this fix.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded in 39ec6e5. Since the remaining SDK readers now use the new ProtocolOptions, the remarks say the SDK deserializes protocol messages with ProtocolOptions, and that protocol types deserialized through DefaultOptions keep case-insensitive matching.

@halter73

Copy link
Copy Markdown
Contributor

I'd rather finish the protocol/user-data split here than leave the remaining readers as follow-ups. Tasks tool results still bind IsError, and stream/ASP.NET error readers still bind Code/Message.

We could add cached, case-sensitive McpJsonUtilities.ProtocolOptions for protocol deserialization throughout the SDK, keeping DefaultOptions and caller-supplied options for user-defined data. If we want to avoid new public API, a shared internal helper under src/Common would work too, but a public preset would also be useful to consumers deserializing protocol messages themselves.

@tarekgh I know you suggested tracking these separately, but fixing the remaining protocol-field casing inconsistencies together seems manageable in one PR. Does that sound good to you?

@tarekgh

tarekgh commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

@halter73 Yes, that sounds good to me. My suggestion to track these separately was to keep the original fix focused, but I agree that finishing the protocol/user-data split in this PR is cleaner.

A cached, read-only McpJsonUtilities.ProtocolOptions preset seems useful both internally and for consumers deserializing protocol messages themselves. It would also give extension packages a consistent contract without needing access to the internal context.

Let's use it for the remaining protocol readers, including task-based tool results and stream/ASP.NET error details, while retaining DefaultOptions and caller-supplied options for user-defined data. We should preserve source-generated/Native AOT support and add regression coverage for those paths, along with confirmation that nested user-data binding remains unchanged.

That would let us document the protocol boundary consistently rather than carrying the known internal exceptions.

@FenjuFu Would you be able to include these changes in this PR, or would you like help with the implementation or tests?

Protocol DTOs were deserialized with the source-generated context, which uses the web defaults and binds property names case-insensitively, while the JSON-RPC envelope converter matches its properties exactly. A request such as tools/call with params.Name was therefore dispatched even though middleware inspecting params.name would not recognize it.

The source-generated context now matches property names case-sensitively. DefaultOptions keeps case-insensitive matching because it is also used for user-defined types such as tool parameters.

Fixes modelcontextprotocol#1897

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Add McpJsonUtilities.ProtocolOptions, a cached read-only copy of DefaultOptions that matches property names case-sensitively, and use it wherever the SDK deserializes protocol messages through options rather than the source-generated context: the stdio client and server transports, the ASP.NET Core Streamable HTTP and SSE request reader, and the Tasks extension (task-based CallToolResult, InputRequest, InputResponse and error details).

DefaultOptions and caller-supplied options are still used for user-defined data. Its docs now point to ProtocolOptions and state that protocol types deserialized through DefaultOptions keep case-insensitive matching.

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
@FenjuFu
FenjuFu force-pushed the fix/protocol-property-casing branch from e09ea77 to 39ec6e5 Compare October 18, 2026 07:16
@FenjuFu

FenjuFu commented Oct 18, 2026

Copy link
Copy Markdown
Author

@halter73 @tarekgh Sure, done in 39ec6e5 (also rebased onto main):

  • Added McpJsonUtilities.ProtocolOptions, a cached read-only copy of DefaultOptions with case-sensitive matching. It comes from the same source-generated context, so AOT/trimming is unaffected.
  • Switched the remaining protocol readers to it: the stdio client/server transports, the ASP.NET Core Streamable HTTP/SSE request reader, and all protocol types in the Tasks extension (task-based CallToolResult, InputRequest/InputResponse, error details).
  • DefaultOptions and caller-supplied options stay in place for user data. Its remarks now describe the boundary as suggested in the inline comment.
  • Tests for alternately cased error.Code/Message and CallToolResult Content/IsError (ProtocolOptions vs DefaultOptions), the stdio transport path, and a nested user DTO through a tool.

Two things I left for you to decide: McpApps.SerializerOptions is public and derived from DefaultOptions, so I didn't change it; and since the OAuth metadata types share the context, their parsing becomes case-sensitive too, which belongs in the release notes. The PR description is updated.

net10.0 locally: core 2414 passed / 6 skipped, AspNetCore 636 passed / 31 skipped, 0 failed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON property-name casing is inconsistent between JSON-RPC envelope and request params

3 participants