Skip to content

Elicitation schema properties uses BTreeMap, discarding the server's declared field order #1109

Description

@SamMorrowDrums

Summary

ElicitationSchema.properties is typed as BTreeMap<String, PrimitiveSchemaDefinition>, so rmcp
re-orders elicitation form fields alphabetically on both deserialize and serialize. The order the
server declared on the wire is unrecoverable through the typed API.

Elicitation schemas are the one place in MCP where a JSON object's key order carries presentation
meaning: it is the order a client renders the form fields in. A server that declares

{"type":"object","properties":{"firstName":{...},"lastName":{...},"email":{...}}}

is seen by an rmcp client as email, firstName, lastName.

Affected code (3.1.0)

  • crates/rmcp/src/model/elicitation_schema.rs:1126ElicitationSchema.properties: BTreeMap<…>
  • crates/rmcp/src/model/elicitation_schema.rs:1262 — same field on ElicitationSchemaBuilder

Because the field is shared, this affects all three paths that carry an elicitation schema:

  1. legacy server-initiated elicitation/create requests (≤ 2025-06-18),
  2. SEP-2322 task steering — TaskPayload::InputRequired { input_requests } (model/task.rs:126),
  3. 2026-07-28 non-task MRTR — InputRequiredResult.inputRequests (model/mrtr.rs:235).

So it applies equally to servers and clients, and it survived the transition to MRTR.

Why consumers cannot work around it

serde_json's preserve_order feature does not help. We enable it, and Cargo's feature
unification means rmcp's own serde_json::Value::Object is IndexMap-backed in our builds and
does preserve order — but properties never passes through Value. It deserializes straight into
BTreeMap, so the ordering is discarded before any consumer can observe it.

The only workaround is to tap raw JSON ahead of rmcp's decode, which means decorating every
transport (stdio AsyncRead, Streamable HTTP, SSE, and any in-process bridge) and carrying a
request-id correlation store alongside. We implemented exactly that and are trying to delete it —
it is a lot of accidental machinery for what is a one-type change upstream.

Suggested fix

Use an order-preserving map for propertiesindexmap::IndexMap<String, PrimitiveSchemaDefinition>.
indexmap is already in the tree transitively via serde_json, and its serde impl is a drop-in for
BTreeMap (IndexMap deserializes in encounter order and serializes in insertion order).

If a hard dependency is unwelcome, gating it behind a feature that swaps the map type would also
work, though an unconditional change seems preferable given the field's semantics — nothing relies
on properties being sorted, and BTreeMap's ordering guarantee is not meaningful for a JSON
Schema property bag.

InputRequests = BTreeMap<String, InputRequest> (model/mrtr.rs:91) and InputResponses
(model/mrtr.rs:100) have the same shape. Those keys are correlation identifiers looked up by name,
so the impact is much weaker — but if servers ever intend inputRequests to be presented in
declaration order, the same argument applies. Happy to scope a PR to properties alone.

Relationship to #1097

Same defect class as #1097 (which we
hit independently, and which is why we are on 3.1.0): the typed model silently discards information
that was present on the wire, and round-tripping through rmcp is not faithful to the input. This one
loses ordering rather than fields, so it degrades rendering rather than breaking protocol flow —
lower severity, but the same underlying shape.

I'm glad to open a PR if the IndexMap direction is agreeable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions