Skip to main content

flymcprust_core/
mrtr.rs

1//! Multi round-trip requests (MRTR): handlers ask the client for input
2//! (elicitation, sampling, roots) by returning an `InputRequiredResult`; the
3//! client retries the original request with the answers.
4//!
5//! The SDK runs handlers in *replay* style: every round runs the handler from
6//! the start. [`Ctx::elicit`] (and its siblings) returns the answer when the
7//! client already sent it, in this round or an earlier one; otherwise it
8//! records the request and fails with an internal "input required" error that
9//! the runtime turns into the `InputRequiredResult`. Code before an input call
10//! therefore runs once per round, so it should be free of side effects (or
11//! idempotent).
12//!
13//! Answers from earlier rounds travel in `requestState`, which the client
14//! must echo back. The state is HMAC-SHA256 signed and bound to the method,
15//! the target (tool or prompt name, resource URI), a digest of the arguments
16//! and an expiry, so a client cannot forge answers, replay them on another
17//! request, or use them after the expiry. Answers the client sends in this
18//! round's `inputResponses` are client input like the arguments themselves.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::sync::{Mutex, PoisonError};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24use base64::Engine as _;
25use base64::engine::general_purpose::URL_SAFE_NO_PAD;
26use hmac::{Hmac, KeyInit as _, Mac as _};
27use schemars::JsonSchema;
28use serde::de::DeserializeOwned;
29use serde::{Deserialize, Serialize};
30use serde_json::{Map, Value, json};
31use sha2::{Digest as _, Sha256};
32
33use crate::context::Ctx;
34use crate::error::McpError;
35use crate::spec::{self, methods};
36use crate::tool::{ToolError, schema_for};
37
38type HmacSha256 = Hmac<Sha256>;
39
40/// Minimum length of a `requestState` signing key.
41pub const MIN_REQUEST_STATE_KEY_BYTES: usize = 32;
42
43/// Version of the `requestState` payload format.
44const STATE_VERSION: u8 = 1;
45
46/// Domain separation for `requestState` signatures: a token signed for one
47/// purpose (such as a legacy session) is never valid for another.
48const SIGNATURE_CONTEXT: &[u8] = b"flymcprust/requestState/v1\0";
49
50/// Signs and verifies `requestState`.
51pub(crate) struct StateCodec {
52    key: Vec<u8>,
53    ttl: Duration,
54}
55
56impl std::fmt::Debug for StateCodec {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("StateCodec")
59            .field("ttl", &self.ttl)
60            .finish_non_exhaustive()
61    }
62}
63
64/// The signed content of `requestState`.
65#[derive(Serialize, Deserialize)]
66struct Payload {
67    v: u8,
68    /// Method of the original request.
69    m: String,
70    /// Tool or prompt name, or resource URI.
71    t: String,
72    /// Digest of the arguments.
73    a: String,
74    /// Expiry (seconds since the Unix epoch).
75    e: u64,
76    /// Rounds completed so far.
77    n: u32,
78    /// Answers collected in earlier rounds.
79    r: BTreeMap<String, Value>,
80    /// Digest of the authenticated principal (OAuth), if any.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    p: Option<String>,
83}
84
85impl StateCodec {
86    pub(crate) fn new(key: Vec<u8>, ttl: Duration) -> Self {
87        Self { key, ttl }
88    }
89
90    fn mac(&self, context: &[u8], data: &[u8]) -> Option<HmacSha256> {
91        let mut mac = HmacSha256::new_from_slice(&self.key).ok()?;
92        mac.update(context);
93        mac.update(data);
94        Some(mac)
95    }
96
97    /// Serializes and signs a payload for one purpose (`context`). The token
98    /// is URL-safe base64 plus a dot: visible ASCII, usable in HTTP headers.
99    pub(crate) fn seal_for<T: Serialize>(&self, context: &[u8], payload: &T) -> Option<String> {
100        let json = serde_json::to_vec(payload).ok()?;
101        let body = URL_SAFE_NO_PAD.encode(json);
102        let tag = self.mac(context, body.as_bytes())?.finalize().into_bytes();
103        Some(format!("{body}.{}", URL_SAFE_NO_PAD.encode(tag)))
104    }
105
106    /// Verifies a token signed with [`seal_for`](Self::seal_for) for the
107    /// same purpose (in constant time) before decoding it.
108    pub(crate) fn open_for<T: DeserializeOwned>(&self, context: &[u8], token: &str) -> Option<T> {
109        let (body, tag) = token.rsplit_once('.')?;
110        let tag = URL_SAFE_NO_PAD.decode(tag).ok()?;
111        self.mac(context, body.as_bytes())?
112            .verify_slice(&tag)
113            .ok()?;
114        let json = URL_SAFE_NO_PAD.decode(body).ok()?;
115        serde_json::from_slice(&json).ok()
116    }
117
118    fn seal(&self, payload: &Payload) -> Result<String, McpError> {
119        self.seal_for(SIGNATURE_CONTEXT, payload)
120            .ok_or_else(McpError::internal)
121    }
122
123    fn open(&self, token: &str, round: &RoundKey<'_>) -> Result<Payload, McpError> {
124        let digest = round.args_digest();
125        let invalid = || McpError::invalid_params("Invalid or expired requestState");
126        let payload: Payload = self
127            .open_for(SIGNATURE_CONTEXT, token)
128            .ok_or_else(invalid)?;
129        if payload.v != STATE_VERSION
130            || payload.m != round.method
131            || payload.t != round.target
132            || payload.a != digest
133            || payload.p.as_deref() != round.principal
134            || payload.e < now_secs()
135        {
136            return Err(invalid());
137        }
138        Ok(payload)
139    }
140}
141
142pub(crate) fn now_secs() -> u64 {
143    SystemTime::now()
144        .duration_since(UNIX_EPOCH)
145        .map_or(0, |d| d.as_secs())
146}
147
148/// What identifies the original request across rounds. Digests are only
149/// computed when MRTR is actually used.
150pub(crate) struct RoundKey<'a> {
151    pub(crate) method: &'static str,
152    pub(crate) target: &'a str,
153    pub(crate) arguments: Args<'a>,
154    /// Digest of the authenticated principal: a state issued to one user is
155    /// useless to another.
156    pub(crate) principal: Option<&'a str>,
157}
158
159/// The arguments of the original request.
160#[derive(Clone, Copy)]
161pub(crate) enum Args<'a> {
162    None,
163    /// Raw JSON (`tools/call`).
164    Raw(&'a str),
165    /// String arguments (`prompts/get`).
166    Map(&'a BTreeMap<String, String>),
167}
168
169impl RoundKey<'_> {
170    /// Digest of the arguments, insensitive to whitespace and key order.
171    fn args_digest(&self) -> String {
172        let canonical = match self.arguments {
173            Args::None => Vec::new(),
174            Args::Raw(raw) => serde_json::from_str::<Value>(raw)
175                .ok()
176                .and_then(|mut v| {
177                    v.sort_all_objects();
178                    serde_json::to_vec(&v).ok()
179                })
180                .unwrap_or_default(),
181            Args::Map(map) => serde_json::to_vec(map).unwrap_or_default(),
182        };
183        URL_SAFE_NO_PAD.encode(Sha256::digest(&canonical))
184    }
185}
186
187/// MRTR data of one round, carried by [`Ctx`].
188#[derive(Debug, Default)]
189pub(crate) struct Round {
190    /// Rounds completed before this one.
191    number: u32,
192    /// Answers from earlier rounds (from `requestState`) and from this
193    /// round's `inputResponses`.
194    responses: BTreeMap<String, Value>,
195    /// Requests issued in this round that have no answer yet.
196    pending: Mutex<Vec<(String, Value)>>,
197    /// Answers the handler read; only these are carried to the next round,
198    /// so unrequested data is never signed into `requestState`.
199    used: Mutex<BTreeSet<String>>,
200}
201
202impl Round {
203    /// Collects the answers of a request, verifying its `requestState`.
204    pub(crate) fn start(
205        codec: &StateCodec,
206        key: &RoundKey<'_>,
207        input_responses: Option<Value>,
208        request_state: Option<&str>,
209    ) -> Result<Self, McpError> {
210        let (number, mut responses) = match request_state {
211            Some(token) => {
212                let payload = codec.open(token, key)?;
213                (payload.n, payload.r)
214            }
215            None => (0, BTreeMap::new()),
216        };
217        match input_responses {
218            None | Some(Value::Null) => {}
219            Some(Value::Object(map)) => {
220                for (k, v) in map {
221                    responses.entry(k).or_insert(v);
222                }
223            }
224            Some(_) => {
225                return Err(McpError::invalid_params(
226                    "`inputResponses` must be an object",
227                ));
228            }
229        }
230        Ok(Self {
231            number,
232            responses,
233            pending: Mutex::new(Vec::new()),
234            used: Mutex::new(BTreeSet::new()),
235        })
236    }
237
238    /// Builds the `InputRequiredResult` for the requests issued this round.
239    pub(crate) fn input_required(
240        &self,
241        codec: &StateCodec,
242        key: &RoundKey<'_>,
243        meta: Option<&spec::ResultMetaObject>,
244    ) -> Result<Value, McpError> {
245        let pending =
246            std::mem::take(&mut *self.pending.lock().unwrap_or_else(PoisonError::into_inner));
247        let expiry = now_secs().saturating_add(codec.ttl.as_secs());
248        let state = codec.seal(&Payload {
249            v: STATE_VERSION,
250            m: key.method.to_owned(),
251            t: key.target.to_owned(),
252            a: key.args_digest(),
253            e: expiry,
254            p: key.principal.map(str::to_owned),
255            n: self.number.saturating_add(1),
256            r: {
257                let used = self.used.lock().unwrap_or_else(PoisonError::into_inner);
258                self.responses
259                    .iter()
260                    .filter(|(k, _)| used.contains(k.as_str()))
261                    .map(|(k, v)| (k.clone(), v.clone()))
262                    .collect()
263            },
264        })?;
265        let mut result = Map::new();
266        result.insert("resultType".to_owned(), Value::from("input_required"));
267        if !pending.is_empty() {
268            result.insert(
269                "inputRequests".to_owned(),
270                Value::Object(pending.into_iter().collect()),
271            );
272        }
273        result.insert("requestState".to_owned(), Value::from(state));
274        if let Some(meta) = meta {
275            let meta = serde_json::to_value(meta).map_err(|_| McpError::internal())?;
276            result.insert("_meta".to_owned(), meta);
277        }
278        Ok(Value::Object(result))
279    }
280
281    fn response(&self, key: &str) -> Option<&Value> {
282        let value = self.responses.get(key)?;
283        self.used
284            .lock()
285            .unwrap_or_else(PoisonError::into_inner)
286            .insert(key.to_owned());
287        Some(value)
288    }
289
290    fn push(&self, key: &str, request: Value) {
291        let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner);
292        if !pending.iter().any(|(k, _)| k == key) {
293            pending.push((key.to_owned(), request));
294        }
295    }
296}
297
298/// The outcome of an elicitation.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum Elicitation<T> {
301    /// The user submitted the form.
302    Accepted(T),
303    /// The user explicitly declined.
304    Declined,
305    /// The user dismissed the request without choosing.
306    Cancelled,
307}
308
309#[derive(Deserialize)]
310struct ElicitAnswer {
311    action: String,
312    #[serde(default)]
313    content: Option<Value>,
314}
315
316/// Which client capability an input request needs.
317#[derive(Clone, Copy)]
318enum Needs {
319    Elicitation,
320    Sampling,
321    Roots,
322}
323
324impl Ctx {
325    /// The number of input rounds completed before this attempt: `0` on the
326    /// first call, `1` after the client answered once, and so on.
327    #[must_use]
328    pub fn input_round(&self) -> u32 {
329        self.round().map_or(0, |r| r.number)
330    }
331
332    /// The client's answer for `key`, decoded as `T`, if it sent one in this
333    /// round or an earlier one.
334    ///
335    /// # Errors
336    ///
337    /// Returns an invalid-params error (`-32602`) if the answer does not
338    /// decode as `T`.
339    pub fn input_response<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ToolError> {
340        let Some(value) = self.round().and_then(|r| r.response(key)) else {
341            return Ok(None);
342        };
343        T::deserialize(value)
344            .map(Some)
345            .map_err(|_| invalid_response(key))
346    }
347
348    /// Asks the user for structured input with a form generated from `T`
349    /// (a flat struct of strings, numbers, booleans and enums). Declining or
350    /// cancelling becomes a [`ToolError::user`]; use
351    /// [`elicit_with_action`](Self::elicit_with_action) to handle them.
352    ///
353    /// `key` identifies the question across rounds and must be unique within
354    /// the handler. Several questions can be asked in one round by awaiting
355    /// them together (for example with `tokio::join!`).
356    ///
357    /// # Errors
358    ///
359    /// Fails with an "input required" error when the answer is not available
360    /// yet (propagate it with `?`), `-32021` if the client does not support
361    /// form elicitation, and `-32602` if the answer is malformed.
362    pub async fn elicit<T: DeserializeOwned + JsonSchema>(
363        &self,
364        key: &str,
365        message: impl Into<String>,
366    ) -> Result<T, ToolError> {
367        match self.elicit_with_action::<T>(key, message).await? {
368            Elicitation::Accepted(value) => Ok(value),
369            Elicitation::Declined => Err(ToolError::user(
370                "The user declined to provide the requested information.",
371            )),
372            Elicitation::Cancelled => Err(ToolError::user("The user cancelled the request.")),
373        }
374    }
375
376    /// Like [`elicit`](Self::elicit), but returns declined and cancelled
377    /// answers instead of failing.
378    ///
379    /// # Errors
380    ///
381    /// See [`elicit`](Self::elicit).
382    pub async fn elicit_with_action<T: DeserializeOwned + JsonSchema>(
383        &self,
384        key: &str,
385        message: impl Into<String>,
386    ) -> Result<Elicitation<T>, ToolError> {
387        if let Some(answer) = self.input_response::<ElicitAnswer>(key)? {
388            return match answer.action.as_str() {
389                "accept" => {
390                    let content = answer.content.unwrap_or_else(|| json!({}));
391                    T::deserialize(&content)
392                        .map(Elicitation::Accepted)
393                        .map_err(|_| invalid_response(key))
394                }
395                "decline" => Ok(Elicitation::Declined),
396                "cancel" => Ok(Elicitation::Cancelled),
397                _ => Err(invalid_response(key)),
398            };
399        }
400        let schema = elicitation_schema::<T>()?;
401        let request = json!({
402            "method": methods::ELICITATION_CREATE,
403            "params": { "message": message.into(), "requestedSchema": schema },
404        });
405        Err(self.ask(key, request, Needs::Elicitation))
406    }
407
408    /// Like [`elicit_with_action`](Self::elicit_with_action), with a
409    /// hand-written `requestedSchema` (for default values, titled enums or
410    /// multi-select fields, which a derived schema cannot express). The
411    /// accepted content comes back as JSON, for the handler to validate.
412    ///
413    /// # Errors
414    ///
415    /// See [`elicit`](Self::elicit).
416    pub async fn elicit_with_schema(
417        &self,
418        key: &str,
419        message: impl Into<String>,
420        requested_schema: Value,
421    ) -> Result<Elicitation<Map<String, Value>>, ToolError> {
422        if let Some(answer) = self.input_response::<ElicitAnswer>(key)? {
423            return match answer.action.as_str() {
424                "accept" => match answer.content.unwrap_or_else(|| json!({})) {
425                    Value::Object(content) => Ok(Elicitation::Accepted(content)),
426                    _ => Err(invalid_response(key)),
427                },
428                "decline" => Ok(Elicitation::Declined),
429                "cancel" => Ok(Elicitation::Cancelled),
430                _ => Err(invalid_response(key)),
431            };
432        }
433        let request = json!({
434            "method": methods::ELICITATION_CREATE,
435            "params": { "message": message.into(), "requestedSchema": requested_schema },
436        });
437        Err(self.ask(key, request, Needs::Elicitation))
438    }
439
440    /// Asks the client to sample from a language model.
441    ///
442    /// # Errors
443    ///
444    /// Fails with an "input required" error when the answer is not available
445    /// yet (propagate it with `?`), `-32021` if the client does not support
446    /// sampling, and `-32602` if the answer is malformed.
447    #[deprecated(note = "Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577).")]
448    #[allow(deprecated)]
449    pub async fn sample(
450        &self,
451        key: &str,
452        params: spec::CreateMessageRequestParams,
453    ) -> Result<spec::CreateMessageResult, ToolError> {
454        if let Some(answer) = self.input_response(key)? {
455            return Ok(answer);
456        }
457        let request = json!({ "method": methods::SAMPLING_CREATE_MESSAGE, "params": params });
458        Err(self.ask(key, request, Needs::Sampling))
459    }
460
461    /// Asks the client for its filesystem roots.
462    ///
463    /// # Errors
464    ///
465    /// Fails with an "input required" error when the answer is not available
466    /// yet (propagate it with `?`), `-32021` if the client does not support
467    /// roots, and `-32602` if the answer is malformed.
468    #[deprecated(note = "Roots are deprecated as of protocol version 2026-07-28 (SEP-2577).")]
469    #[allow(deprecated)]
470    pub async fn list_roots(&self, key: &str) -> Result<spec::ListRootsResult, ToolError> {
471        if let Some(answer) = self.input_response(key)? {
472            return Ok(answer);
473        }
474        let request = json!({ "method": methods::ROOTS_LIST, "params": {} });
475        Err(self.ask(key, request, Needs::Roots))
476    }
477
478    /// Records an input request and returns the error that ends this round.
479    fn ask(&self, key: &str, request: Value, needs: Needs) -> ToolError {
480        let Some(round) = self.round() else {
481            return ToolError::internal(std::io::Error::other(
482                "input requests are only available in tools/call, resources/read and prompts/get",
483            ));
484        };
485        #[allow(deprecated)]
486        let caps = self.client_capabilities();
487        #[allow(deprecated)]
488        let (supported, required) = match needs {
489            Needs::Elicitation => (
490                caps.elicitation
491                    .as_ref()
492                    .is_some_and(|e| e.form.is_some() || e.url.is_none()),
493                spec::ClientCapabilities {
494                    elicitation: Some(spec::ClientCapabilitiesElicitation {
495                        form: Some(Map::new()),
496                        url: None,
497                    }),
498                    ..spec::ClientCapabilities::default()
499                },
500            ),
501            Needs::Sampling => (
502                caps.sampling.is_some(),
503                spec::ClientCapabilities {
504                    sampling: Some(spec::ClientCapabilitiesSampling::default()),
505                    ..spec::ClientCapabilities::default()
506                },
507            ),
508            Needs::Roots => (
509                caps.roots.is_some(),
510                spec::ClientCapabilities {
511                    roots: Some(spec::ClientCapabilitiesRoots::default()),
512                    ..spec::ClientCapabilities::default()
513                },
514            ),
515        };
516        if !supported {
517            static HINT: crate::hints::Once = crate::hints::Once::new();
518            crate::hints::warn_once(
519                &HINT,
520                format_args!(
521                    "a client that did not declare the needed capability called a handler \
522                     that asks it for input; it received -32021"
523                ),
524                "check ctx.client_capabilities() before asking if the handler can work \
525                 without the answer",
526            );
527            return ToolError::missing_client_capability(&required);
528        }
529        round.push(key, request);
530        ToolError::input_required()
531    }
532}
533
534fn invalid_response(key: &str) -> ToolError {
535    ToolError::protocol(McpError::invalid_params(format!(
536        "Invalid input response for `{}`",
537        crate::error::truncate(key)
538    )))
539}
540
541/// The `requestedSchema` of an elicitation: the JSON Schema of `T`, which
542/// must be an object of primitive fields. `Option<X>` fields (`"type":
543/// ["x", "null"]`) become plain optional fields.
544fn elicitation_schema<T: JsonSchema>() -> Result<Value, ToolError> {
545    let mut schema = schema_for::<T>();
546    let unsupported = || {
547        ToolError::internal(std::io::Error::other(format!(
548            "`{}` cannot be used with ctx.elicit\n  = help: use a struct whose fields are strings, \
549             numbers, integers, booleans or enums (optionally in Option<..>); forms cannot nest",
550            std::any::type_name::<T>()
551        )))
552    };
553    if schema.get("type").and_then(Value::as_str) != Some("object") {
554        return Err(unsupported());
555    }
556    schema.remove("additionalProperties");
557    let Some(Value::Object(properties)) = schema.get_mut("properties") else {
558        return Err(unsupported());
559    };
560    for property in properties.values_mut() {
561        let Value::Object(property) = property else {
562            return Err(unsupported());
563        };
564        if let Some(Value::Array(types)) = property.get("type") {
565            let non_null: Vec<&Value> = types
566                .iter()
567                .filter(|t| t.as_str() != Some("null"))
568                .collect();
569            if let [single] = non_null.as_slice() {
570                let single = (*single).clone();
571                property.insert("type".to_owned(), single);
572            }
573        }
574        let primitive = matches!(
575            property.get("type").and_then(Value::as_str),
576            Some("string" | "number" | "integer" | "boolean" | "array")
577        ) || property.contains_key("enum")
578            || property.contains_key("oneOf")
579            || property.contains_key("anyOf");
580        if !primitive {
581            return Err(unsupported());
582        }
583    }
584    Ok(Value::Object(schema))
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    fn codec() -> StateCodec {
592        StateCodec::new(vec![7; 32], Duration::from_secs(600))
593    }
594
595    fn key(target: &str) -> RoundKey<'_> {
596        RoundKey {
597            method: "tools/call",
598            target,
599            arguments: Args::Raw(r#"{"a": 1}"#),
600            principal: None,
601        }
602    }
603
604    fn round_trip(codec: &StateCodec, round: &Round) -> String {
605        round.input_required(codec, &key("t"), None).unwrap()["requestState"]
606            .as_str()
607            .unwrap()
608            .to_owned()
609    }
610
611    #[test]
612    fn state_round_trips_and_accumulates_answers() {
613        let codec = codec();
614        let first = Round::start(
615            &codec,
616            &key("t"),
617            Some(json!({ "q1": 1, "unread": 0 })),
618            None,
619        )
620        .unwrap();
621        // The handler reads `q1` only.
622        assert_eq!(first.response("q1"), Some(&json!(1)));
623        let token = round_trip(&codec, &first);
624        let second =
625            Round::start(&codec, &key("t"), Some(json!({ "q2": 2 })), Some(&token)).unwrap();
626        assert_eq!(second.number, 1);
627        assert_eq!(second.response("q1"), Some(&json!(1)));
628        assert_eq!(second.response("q2"), Some(&json!(2)));
629        // Unread answers are not carried over.
630        assert_eq!(second.response("unread"), None);
631    }
632
633    #[test]
634    fn tampered_or_rebound_state_is_rejected() {
635        let codec = codec();
636        let round = Round::start(&codec, &key("t"), None, None).unwrap();
637        let token = round_trip(&codec, &round);
638        let reject = |token: &str, key: RoundKey<'_>| {
639            Round::start(&codec, &key, None, Some(token))
640                .unwrap_err()
641                .code()
642        };
643        assert_eq!(reject(&format!("{token}-TAMPERED"), key("t")), -32602);
644        assert_eq!(reject("garbage", key("t")), -32602);
645        // Another tool, other arguments, or another key.
646        assert_eq!(reject(&token, key("other")), -32602);
647        let mut other_args = key("t");
648        other_args.arguments = Args::Raw(r#"{"a":2}"#);
649        assert_eq!(reject(&token, other_args), -32602);
650        // Another user.
651        let mut other_user = key("t");
652        other_user.principal = Some("someone-else");
653        assert_eq!(reject(&token, other_user), -32602);
654        let other_key = StateCodec::new(vec![8; 32], Duration::from_secs(600));
655        assert!(Round::start(&other_key, &key("t"), None, Some(&token)).is_err());
656    }
657
658    #[test]
659    fn expired_state_is_rejected() {
660        let codec = StateCodec::new(vec![7; 32], Duration::ZERO);
661        let round = Round::start(&codec, &key("t"), None, None).unwrap();
662        let mut payload: Payload = {
663            let token = round_trip(&codec, &round);
664            let body = token.split('.').next().unwrap();
665            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(body).unwrap()).unwrap()
666        };
667        payload.e = now_secs() - 1;
668        let token = codec.seal(&payload).unwrap();
669        assert!(Round::start(&codec, &key("t"), None, Some(&token)).is_err());
670    }
671
672    #[test]
673    fn argument_digest_ignores_whitespace() {
674        let digest = |raw| {
675            RoundKey {
676                method: "tools/call",
677                target: "t",
678                arguments: Args::Raw(raw),
679                principal: None,
680            }
681            .args_digest()
682        };
683        assert_eq!(digest(r#"{"a":1}"#), digest(r#"{ "a" : 1 }"#));
684        assert_eq!(
685            digest(r#"{"a":1,"b":{"y":2,"x":1}}"#),
686            digest(r#"{"b":{"x":1,"y":2},"a":1}"#)
687        );
688        assert_ne!(digest(r#"{"a":1}"#), digest(r#"{"a":2}"#));
689    }
690
691    #[test]
692    fn invalid_input_responses_are_rejected() {
693        let err = Round::start(&codec(), &key("t"), Some(json!(5)), None).unwrap_err();
694        assert_eq!(err.code(), -32602);
695    }
696
697    #[derive(JsonSchema)]
698    #[allow(dead_code)]
699    struct Form {
700        name: String,
701        age: Option<u32>,
702        subscribe: bool,
703    }
704
705    #[test]
706    fn elicitation_schema_is_flat() {
707        let schema = elicitation_schema::<Form>().unwrap();
708        assert_eq!(
709            schema,
710            json!({
711                "type": "object",
712                "properties": {
713                    "name": { "type": "string" },
714                    "age": { "type": "integer", "minimum": 0 },
715                    "subscribe": { "type": "boolean" },
716                },
717                "required": ["name", "subscribe"],
718            })
719        );
720        #[derive(JsonSchema)]
721        #[allow(dead_code)]
722        struct Nested {
723            inner: Form,
724        }
725        assert!(elicitation_schema::<Nested>().is_err());
726    }
727}