Skip to content

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

Open

Description

@nagavitalp

Version

ModelContextProtocol / ModelContextProtocol.AspNetCore 2.1.0

Behavior

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:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "Name": "search_orders",
    "arguments": {}
  }
}

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.

Activity

  1. nagavitalp commented on Sep 28, 2026

    @nagavitalp
    Author

    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.

  2. jeffhandley commented on Oct 7, 2026

    @jeffhandley
    Contributor

    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.

  3. added
    help wantedContributions welcome from those familiar with codebase
    P3Nice to haves, rare edge cases
    on Oct 7, 2026
  4. ZYZ666-RGB commented on Oct 8, 2026

    @ZYZ666-RGB

    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?

  5. FenjuFu commented on Oct 8, 2026

    @FenjuFu

    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:

    1. 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.
    2. 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.
    3. 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.
  6. jeffhandley commented on Oct 14, 2026

    @jeffhandley
    Contributor

    @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.

  7. jeffhandley commented on Oct 14, 2026

    @jeffhandley
    Contributor

    @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.

    SDK (Version) { Method: "tools/call" } { Name: "B" } { name: "a", Name: "B" } { Content: [{ type: "text", text: "B" }] }
    C# (2.2.0) 🟢 No binding¹ 🔴 "B" bound 🔴 "B" bound² 🔴 "B" bound
    C# (proposed) 🟢 No binding¹ 🟢 No binding¹ 🟢 "a" bound 🟡 [] default³
    TypeScript (2.3.1) 🟢 No binding¹ 🟢 No binding¹ 🟢 "a" bound 🟡 [] default / reject³
    Python (2.3.0) 🟢 No binding¹ 🟢 No binding¹ 🟢 "a" bound 🟢 No binding¹
    Go (1.8.0) 🟢 No binding¹ 🟢 "" default¹ 🟢 "a" bound 🟡 Unset¹
    Rust rmcp (3.5.1) 🟢 No binding¹ 🟢 No binding¹ 🟢 "a" bound 🟡 No binding¹
    Ruby (1.7.0) 🟢 No binding¹ 🟢 No binding¹ 🟢 "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 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.

    Go is a genuine consistency exception. Normal request/notification DTOs and custom CallToolResult decoding are case-sensitive, but ordinary result decoding uses standard json.Unmarshal, and embedded input requests/responses also use it. For plain structs, the decoder actually falls back to folded-name matching: e.g. Tools can populate tools; both spellings can overwrite in input order. This is not evidence that Name dispatches a tool.

    Rust and Ruby permit unknown/raw data. Rust defaults missing tool-result content to 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 incoming jsonrpc.

    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.

  8. added a commit that references this issue on Oct 18, 2026
    54e853b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P3Nice to haves, rare edge casesbugSomething isn't workinghelp wantedContributions welcome from those familiar with codebase

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions