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:
- 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.
- 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.
The MRTR example reads the elicited value out of
input_responseswithout looking atactionfirst.examples/servers/src/mrtr.rs, lines 110–115on
main, identical atrmcp-v3.1.1:There is no
actioncheck anywhere on that path, which gives two behaviours:ElicitResultwithaction: "decline"and a leftovercontentis well-formed: the spec's wording for decline is that"The
contentfield is typically omitted", typically rather than MUST NOT. This server reads thatcontent as though the user had accepted.
action: "decline"and nocontent,v["content"]["city"]isNull,as_str()isNone, and theunwrap_orsupplies"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):
and its Response Actions section asks for each of the three to be handled on its own terms:
Suggested fix
Match
actionbefore touchingcontent. This is the smallest version, which keeps the example's onehappy path and does not pick a policy for the two refusals:
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
InputRequiredResultrequesting the missing informationagain, 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 withthe
{"awaiting": "city"}the first round sealed:So
open_jsonserves 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
actionmatch is about four of those lines. Happy to open a PR if thedirection is agreeable.