Skip to content

examples/servers/src/mrtr.rs never checks the elicitation action, so a declined elicitation is answered as if it had been accepted #1147

Description

@zs-dima

The MRTR example reads the elicited value out of input_responses without looking at action first.
examples/servers/src/mrtr.rs, lines 110–115
on main, identical at rmcp-v3.1.1:

let city = request
    .input_responses
    .as_ref()
    .and_then(|r| r.get("city"))
    .and_then(|v| v["content"]["city"].as_str())
    .unwrap_or("your area");

There is no action check anywhere on that path, which gives two behaviours:

  1. A refusal that carries content is read as an answer. An ElicitResult with
    action: "decline" and a leftover content is well-formed: the spec's wording for decline is that
    "The content field is typically omitted", typically rather than MUST NOT. This server reads that
    content as though the user had accepted.
  2. A clean refusal is answered anyway. With action: "decline" and no content,
    v["content"]["city"] is Null, as_str() is None, and the unwrap_or supplies "your area".
    The server replies "It is sunny in your area." to a user who declined to name one.

The spec is normative here (2026-07-28, Elicitation, Error Handling):

"Servers SHOULD NOT assume that elicitation requests will always succeed, and MUST handle cases
where the user declines or cancels the elicitation, or where the client fails to process the request."

and its Response Actions section asks for each of the three to be handled on its own terms:

Servers should handle each state appropriately:

  • Accept: Process the submitted data
  • Decline: Handle explicit decline (e.g., offer alternatives)
  • Cancel: Handle dismissal (e.g., prompt again later)

Suggested fix

Match action before touching content. This is the smallest version, which keeps the example's one
happy path and does not pick a policy for the two refusals:

let response = request.input_responses.as_ref().and_then(|r| r.get("city"));

let city = match response.and_then(|v| v["action"].as_str()) {
    Some("accept") => response
        .and_then(|v| v["content"]["city"].as_str())
        .ok_or_else(|| ErrorData::invalid_params("accepted without a city", None))?,
    _ => {
        return Ok(CallToolResult::success(vec![ContentBlock::text(
            "I cannot look up the weather without a city.",
        )])
        .into());
    }
};

Asking again rather than giving up is equally correct, and arguably the better demonstration: Server
Requirement 8 permits it outright, and MRTR's own Error Handling clause prefers it to an error —
"the server SHOULD respond with a new InputRequiredResult requesting the missing information
again, rather than returning an error"
. It costs a little more churn, since the first round's body
has to move into a helper the retry branch can call, so I left it out of the sketch above.

A smaller thing in the same file

The retry branch opens the request state and binds it to _state (line 106), never comparing it with
the {"awaiting": "city"} the first round sealed:

let _state: serde_json::Value = self.codec.open_json(&sealed).map_err(|_| {
    ErrorData::invalid_params("tampered or unknown request state", None)
})?;

So open_json serves as an integrity check, and the phase inside the envelope is written,
transported, and never read. That is harmless in a one-round example, but the example is what a
multi-round server gets copied from, and there the phase in the envelope is the thing that decides
which answers are being read.

This is an example and not production code, and its job is to show the MRTR round trip in as few
lines as possible. Raising it because it is also the first thing people reach for when they write a
SEP-2322 server, and the action match is about four of those lines. Happy to open a PR if the
direction is agreeable.

Metadata

Metadata

Assignees

Labels

2026-07-28P1High: significant functionality gap or spec violationT-examplesExample code changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions