Skip to main content

protocol_droid/application/server/
dispatch.rs

1//! Routing a method name to the handler that serves it.
2//!
3//! # Why this is not on `Server`
4//!
5//! `Server::call` takes a tool name, because a tool call is the only thing a
6//! handler can answer. A peer, though, sends a _method_ name, and several
7//! methods never reach a tool handler at all — `tools/list` and `ping` have no
8//! handler to dispatch to. The method table lives here rather than on
9//! `Server` so the two stay apart: the builder is how a server is described,
10//! this is how a request against it is routed.
11//!
12//! # What the result is, and is not
13//!
14//! The result is an **IR-level** [`JsonObject`], not a wire frame. There is no
15//! request-side codec yet — `Codec` lifts and lowers a `ToolResult` and nothing
16//! else — so a result built here is the canonical form that T13/T14's transport
17//! will then lower for whichever revision negotiated. The in-process test that
18//! exercises this says the same thing in its own doc comment rather than
19//! pretending to cover the wire.
20//!
21//! # Which fields a listing carries
22//!
23//! Only the ones **every** revision expresses. `title` and `icons` arrived in
24//! `2026-07-28`, so emitting them unconditionally would hand a `2024-11-05`
25//! peer a field its revision has no name for. Gating a field on a revision is
26//! the codec's job and belongs with it; this module stays the common
27//! denominator, and the omission is recorded rather than papered over.
28
29use std::sync::Arc;
30
31use crate::application::mcp::initialize::{read_offer, server_capabilities_json};
32use crate::application::mcp::input_required::{InputRound, capability_for, is_input_required};
33use crate::application::registry::listing::Listing;
34use crate::application::server::builder::{DispatchError, Server};
35use crate::application::server::context::{PeerSession, RequestContext};
36use crate::domain::feature::{Feature, FeatureProfile};
37use crate::domain::idempotency::{Claim, IdempotencyKey};
38use crate::domain::ir::JsonObject;
39use crate::domain::ir::LogLevel;
40use crate::domain::registry::{Primitive, PrimitiveKind, PromptArgument, Registered};
41use crate::domain::{
42    CapabilitySet, Negotiation, ProtocolVersion, SupportedVersions, TransportKind,
43    client_method_defined_in, discover_defined_in, handshake_defined_in, negotiate_server, resolve,
44    transport_allowed,
45};
46
47/// Routes methods to a server's handlers.
48#[derive(Clone)]
49pub struct Dispatcher {
50    server: Arc<Server>,
51}
52
53impl core::fmt::Debug for Dispatcher {
54    /// Prints the server's name and tool count.
55    ///
56    /// For the same reason `PeerSession` does: the registry behind it is a
57    /// large snapshot, and a post-mortem wants to know which server it was.
58    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59        f.debug_struct("Dispatcher")
60            .field("name", &self.server.name())
61            .field("tool_count", &self.server.tool_count())
62            .finish_non_exhaustive()
63    }
64}
65
66/// Which of the two subscription methods a request was.
67///
68/// They share every step — the same gate, the same existence check, the same
69/// empty answer — so one handler takes this instead of a `bool`, which would
70/// make a reader guess at the argument's polarity, and two near-identical
71/// handlers, which would be two places for the check to drift.
72#[derive(Clone, Copy)]
73enum SubscribeOp {
74    /// `resources/subscribe`.
75    Subscribe,
76    /// `resources/unsubscribe`.
77    Unsubscribe,
78}
79
80impl Dispatcher {
81    /// Routes to `server`.
82    #[must_use]
83    pub const fn new(server: Arc<Server>) -> Self {
84        Self { server }
85    }
86
87    /// The server requests are routed to.
88    #[must_use]
89    pub const fn server(&self) -> &Arc<Server> {
90        &self.server
91    }
92
93    /// The server's state map, for a transport handler that needs to
94    /// hand it to a [`RequestContext`].
95    ///
96    /// Cloning the `Arc` is one bump; the contents of the map are not
97    /// touched on the read path.
98    #[must_use]
99    pub fn state(&self) -> &Arc<crate::application::server::context::StateMap> {
100        self.server.state()
101    }
102
103    /// Runs the handshake and returns the session narrowed to what was
104    /// negotiated.
105    ///
106    /// [`dispatch`](Self::dispatch) answers `initialize` and discards the
107    /// negotiated profile, which leaves the caller gating the rest of the
108    /// connection on the profile the session was *constructed* with — a
109    /// revision chosen before the peer existed. That is the right answer only
110    /// when the peer happens to want the newest revision this build serves.
111    ///
112    /// The returned session is what the transport must carry forward: it holds
113    /// the profile resolved for the revision the client actually asked for, so
114    /// every later gate reads that. Transport-specific policy still belongs to
115    /// the transport; the profile does not belong to the caller.
116    ///
117    /// # Errors
118    ///
119    /// As [`dispatch`](Self::dispatch) for `initialize`, and
120    /// [`DispatchError::MalformedInitialize`] when the body does not carry a
121    /// readable version and capability block.
122    pub fn handshake(
123        &self,
124        ctx: &RequestContext,
125        params: &JsonObject,
126    ) -> Result<(JsonObject, PeerSession), DispatchError> {
127        let (version, profile) = negotiate(ctx, params)?;
128        let reply = self.initialize_result(version, profile)?;
129        Ok((reply, ctx.peer().clone().with_profile(profile)))
130    }
131
132    /// The `InitializeResult` body for a settled negotiation.
133    fn initialize_result(
134        &self,
135        version: ProtocolVersion,
136        profile: FeatureProfile,
137    ) -> Result<JsonObject, DispatchError> {
138        // SEP-2575 removed the handshake in `2026-07-28`. There is no result
139        // shape to build, so this is the spec's own answer — the method is not
140        // defined on this revision — and not a `FeatureUnavailable` dressed up
141        // as one.
142        if !handshake_defined_in(version) {
143            return Err(DispatchError::UnknownMethod);
144        }
145        let result = serde_json::json!({
146            "protocolVersion": version.as_wire(),
147            "capabilities": server_capabilities_json(profile),
148            "serverInfo": {
149                "name": self.server.name(),
150                "version": self.server.version(),
151            },
152        });
153        JsonObject::from_value(result).map_err(|_| DispatchError::MalformedInitialize)
154    }
155
156    /// Routes one request.
157    ///
158    /// The gate runs before anything is read out of `params`, so a method the
159    /// session cannot use does not even parse its arguments — the same order
160    /// `RequestContext`'s own gated calls use, and for the same reason.
161    ///
162    /// # Errors
163    ///
164    /// [`DispatchError::UnknownMethod`] for a name no method answers, and
165    /// [`DispatchError::NotCallable`] for a method whose feature the session
166    /// does not have. Both are refusals that wrote nothing.
167    pub async fn dispatch(
168        &self,
169        ctx: &RequestContext,
170        method: &str,
171        params: &JsonObject,
172    ) -> Result<JsonObject, DispatchError> {
173        let version = ctx.peer().negotiated();
174        let answered = self.route(ctx, method, params).await?;
175        // `resultType` belongs on every result at 2026-07-28 and on none
176        // before it, so it is stamped here rather than in each method: there
177        // are ten of them and a per-method call is a call someone forgets.
178        let stamped = stamp_result(answered, Cacheable::No, version);
179        Ok(self.identify(stamped, version))
180    }
181
182    /// Adds `_meta["io.modelcontextprotocol/serverInfo"]` at 2026-07-28.
183    ///
184    /// SEP-2575: with no handshake there is no `InitializeResult` to carry
185    /// `serverInfo`, so a server "SHOULD identify itself in each result's
186    /// `_meta`". Older revisions carry it once, in the handshake, and their
187    /// result schemas have no such key.
188    fn identify(&self, result: JsonObject, version: ProtocolVersion) -> JsonObject {
189        if !result_envelope_defined_in(version) {
190            return result;
191        }
192        let Ok(serde_json::Value::Object(mut members)) =
193            serde_json::from_str::<serde_json::Value>(result.as_str())
194        else {
195            return result;
196        };
197        let meta = members
198            .entry("_meta")
199            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
200        if let Some(meta) = meta.as_object_mut() {
201            meta.insert(
202                "io.modelcontextprotocol/serverInfo".to_owned(),
203                serde_json::json!({
204                    "name": self.server.name(),
205                    "version": self.server.version(),
206                }),
207            );
208        }
209        JsonObject::from_value(serde_json::Value::Object(members)).unwrap_or(result)
210    }
211
212    /// The SEP-2322 round a retry carries, verified.
213    ///
214    /// Both members are optional and both are read only here. A `requestState`
215    /// that does not open under this server's key is refused before any
216    /// handler sees it — the handler is only ever handed a state this server
217    /// issued.
218    fn input_round(&self, params: &JsonObject) -> Result<InputRound, DispatchError> {
219        let responses = match parsed_param(params, "inputResponses")? {
220            None | Some(serde_json::Value::Null) => None,
221            Some(serde_json::Value::Object(map)) => Some(map),
222            Some(_) => return Err(DispatchError::MalformedParams),
223        };
224        let state = match parsed_param(params, "requestState")? {
225            None | Some(serde_json::Value::Null) => None,
226            Some(serde_json::Value::String(sealed)) => {
227                let key = self
228                    .server
229                    .request_state_key()
230                    .ok_or(DispatchError::TamperedRequestState)?;
231                Some(
232                    key.open(&sealed)
233                        .ok_or(DispatchError::TamperedRequestState)?
234                        .to_owned(),
235                )
236            }
237            Some(_) => return Err(DispatchError::MalformedParams),
238        };
239        Ok(InputRound {
240            responses,
241            state,
242            key: self.server.request_state_key().cloned(),
243        })
244    }
245
246    /// Checks an interim result against what the request may receive.
247    ///
248    /// Two rules from SEP-2322 and one from SEP-2575. An `InputRequiredResult`
249    /// exists only at `2026-07-28` and later, and only on `tools/call`,
250    /// `prompts/get` and `resources/read` — "servers MUST NOT send
251    /// `InputRequiredResult` responses on any client requests other than the
252    /// supported ones". And every input request it carries needs the client
253    /// capability for that request, which the client must have declared on
254    /// *this* request: otherwise the answer is `-32021`.
255    fn check_interim(
256        ctx: &RequestContext,
257        method: &str,
258        result: JsonObject,
259    ) -> Result<JsonObject, DispatchError> {
260        let Ok(serde_json::Value::Object(members)) =
261            serde_json::from_str::<serde_json::Value>(result.as_str())
262        else {
263            return Ok(result);
264        };
265        if !is_input_required(&members) {
266            return Ok(result);
267        }
268        if !accepts_input_round(method) || !result_envelope_defined_in(ctx.peer().negotiated()) {
269            return Err(DispatchError::HandlerFailed(
270                "an InputRequiredResult is not defined for this request".to_owned(),
271            ));
272        }
273        let mut required: Vec<&'static str> = Vec::new();
274        if let Some(requests) = members
275            .get("inputRequests")
276            .and_then(serde_json::Value::as_object)
277        {
278            for request in requests.values() {
279                let Some(capability) = request
280                    .get("method")
281                    .and_then(serde_json::Value::as_str)
282                    .and_then(capability_for)
283                else {
284                    continue;
285                };
286                let feature = match capability {
287                    "sampling" => Feature::Sampling,
288                    "elicitation" => Feature::Elicitation,
289                    _ => Feature::Roots,
290                };
291                if !ctx.peer().allows(feature) && !required.contains(&capability) {
292                    required.push(capability);
293                }
294            }
295        }
296        if !required.is_empty() {
297            return Err(DispatchError::MissingClientCapability { required });
298        }
299        Ok(result)
300    }
301
302    /// Routes one method to its handler.
303    ///
304    /// Split from [`dispatch`](Self::dispatch) so the result envelope is added
305    /// once, on the way out, instead of at each of the ten places a result is
306    /// built.
307    async fn route(
308        &self,
309        ctx: &RequestContext,
310        method: &str,
311        params: &JsonObject,
312    ) -> Result<JsonObject, DispatchError> {
313        let version = ctx.peer().negotiated();
314        // A method the negotiated revision does not define is `-32601`
315        // whatever this server could otherwise do with it: answering `ping` on
316        // `2026-07-28` would tell a client the method exists there.
317        if !client_method_defined_in(version, method) {
318            return Err(DispatchError::UnknownMethod);
319        }
320        let round;
321        let ctx = if accepts_input_round(method) && result_envelope_defined_in(version) {
322            round = ctx.clone().with_input_round(self.input_round(params)?);
323            &round
324        } else {
325            ctx
326        };
327        let answered = self.route_defined(ctx, method, params).await?;
328        Self::check_interim(ctx, method, answered)
329    }
330
331    /// Routes a method the negotiated revision defines.
332    async fn route_defined(
333        &self,
334        ctx: &RequestContext,
335        method: &str,
336        params: &JsonObject,
337    ) -> Result<JsonObject, DispatchError> {
338        match method {
339            // `initialize` is the one method with no gate, and for the
340            // opposite reason to `ping`. `ping` may be answered on a session
341            // that already exists; `initialize` is the request that *creates*
342            // the session, so consulting the feature profile to decide whether
343            // to serve it would ask the session whether it exists and take the
344            // answer from something that has not been built yet. The
345            // negotiation that does decide it has already happened — the
346            // revision in the result is the one the session settled on — so
347            // the gate has nothing left to contribute here.
348            //
349            // The one thing it *does* still have to contribute is which
350            // revision that is: on `2026-07-28` there is no handshake to
351            // perform, and the handler refuses rather than inventing one.
352            "initialize" => self.initialize(ctx, params),
353            // SEP-2575's replacement for the handshake, and defined only where
354            // the handshake is not. The two are complementary, so a revision
355            // never has both.
356            "server/discover" => Self::discover(ctx),
357            // `ping` is defined in every revision that has a session and
358            // answers on an otherwise idle one, so gating it would refuse the
359            // request it exists for.
360            "ping" => Ok(JsonObject::default()),
361            "tools/list" => self.list(ctx, PrimitiveKind::Tool, "tools"),
362            "resources/list" => self.list(ctx, PrimitiveKind::Resource, "resources"),
363            "resources/templates/list" => {
364                self.list(ctx, PrimitiveKind::ResourceTemplate, "resourceTemplates")
365            }
366            "prompts/list" => self.list(ctx, PrimitiveKind::Prompt, "prompts"),
367            "tools/call" => self.call_tool(ctx, params).await,
368            "resources/read" => self.read_resource(ctx, params).await,
369            "prompts/get" => self.get_prompt(ctx, params).await,
370            "logging/setLevel" => Self::set_log_level(ctx, params),
371            "completion/complete" => Self::complete(ctx, params),
372            "resources/subscribe" => self.subscribe(ctx, params, SubscribeOp::Subscribe),
373            "resources/unsubscribe" => self.subscribe(ctx, params, SubscribeOp::Unsubscribe),
374            _ => Err(DispatchError::UnknownMethod),
375        }
376    }
377
378    /// Serves `server/discover`: what this server speaks, and for how long a
379    /// client may keep the answer.
380    ///
381    /// SEP-2575 removed `initialize` from `2026-07-28` and made every request
382    /// carry its own version and capabilities in `_meta`. That is enough to
383    /// *use* the server and not enough to *choose* one, which is the gap this
384    /// fills: a client with no session and no `initialize` has no other way to
385    /// learn the revisions on offer.
386    ///
387    /// The capabilities are the profile's own advertisement rather than a
388    /// separate list, because a second list is a second opinion: a client that
389    /// discovered `tools` and then found `tools/call` refused would have been
390    /// told something this crate does not do.
391    ///
392    /// `ttlMs` is `0`. This server has no cache and re-derives the answer on
393    /// every call, so advertising a lifetime it cannot honour would tell a
394    /// client to reuse a result whose backing registry may since have changed.
395    /// Zero is the honest value and the specification permits it: "should be
396    /// considered immediately stale".
397    ///
398    /// `cacheScope` is `private`. The advertisement describes what this
399    /// process can do, and there is nothing user-specific in it, but a
400    /// capability set is also a map of what an operator has turned on, and
401    /// `"public"` invites an intermediary to share it across authorization
402    /// contexts. `"private"` is the safe reading of the same answer.
403    fn discover(ctx: &RequestContext) -> Result<JsonObject, DispatchError> {
404        if !discover_defined_in(ctx.peer().negotiated()) {
405            return Err(DispatchError::UnknownMethod);
406        }
407        let result = serde_json::json!({
408            "resultType": "complete",
409            "supportedVersions": SupportedVersions::all()
410                .map_err(|_| DispatchError::UnnegotiableVersion)?
411                .into_iter()
412                .rev()
413                .map(|version| version.as_wire().into_owned())
414                .collect::<Vec<_>>(),
415            "ttlMs": 0,
416            "cacheScope": "private",
417            "capabilities": server_capabilities_json(ctx.peer().profile()),
418        });
419        JsonObject::from_value(result).map_err(|_| DispatchError::MalformedParams)
420    }
421
422    /// Serves `resources/subscribe` and `resources/unsubscribe`.
423    ///
424    /// Both answer the empty object. A subscription is recorded against the
425    /// session's own sink, so [`ServerHandle::notify_resource_updated`] reaches
426    /// exactly the sessions that asked, and it ends with the session.
427    ///
428    /// [`ServerHandle::notify_resource_updated`]: crate::application::server::ServerHandle::notify_resource_updated
429    fn subscribe(
430        &self,
431        ctx: &RequestContext,
432        params: &JsonObject,
433        op: SubscribeOp,
434    ) -> Result<JsonObject, DispatchError> {
435        Self::require(ctx, Feature::ResourcesSubscribe)?;
436        let uri = string_param(params, "uri")?;
437        // The URI must be one this server lists. Accepting a subscription for
438        // a resource that does not exist is how a client ends up waiting for
439        // an update that can never come, and the peer cannot tell that from a
440        // server that simply has nothing to report.
441        if self.server.reader(&uri).is_none() {
442            return Err(DispatchError::UnknownResource);
443        }
444        let handle = self.server.handle();
445        match op {
446            SubscribeOp::Subscribe => handle.watch_resource(ctx.peer().sink(), &uri, None),
447            SubscribeOp::Unsubscribe => handle.unwatch_resource(ctx.peer().sink(), &uri),
448        }
449        Ok(JsonObject::default())
450    }
451
452    /// Serves `resources/read`.
453    ///
454    /// Not idempotency-keyed, unlike `tools/call`. A read has no side effect
455    /// the peer can observe, so a retried read costs another read and nothing
456    /// else — while a key would make two legitimate concurrent reads of the
457    /// same URI collide into `DuplicateInFlight`, which is exactly wrong for a
458    /// request whose whole purpose is to be repeatable.
459    async fn read_resource(
460        &self,
461        ctx: &RequestContext,
462        params: &JsonObject,
463    ) -> Result<JsonObject, DispatchError> {
464        Self::require(ctx, Feature::Resources)?;
465        let uri = string_param(params, "uri")?;
466        let reader = self
467            .server
468            .reader(&uri)
469            .ok_or(DispatchError::UnknownResource)?;
470        // One of the five cacheable results at 2026-07-28, and the only one
471        // that is not a listing — which is exactly why it is marked here
472        // rather than inferred from the method name.
473        Ok(stamp_result(
474            reader(ctx.clone(), uri).await?,
475            Cacheable::Yes,
476            ctx.peer().negotiated(),
477        ))
478    }
479
480    /// Serves `prompts/get`.
481    ///
482    /// Also not idempotency-keyed, for the same reason as `read_resource`: a
483    /// prompt getter produces a message list, and running it twice produces the
484    /// same list twice at no cost to anybody.
485    async fn get_prompt(
486        &self,
487        ctx: &RequestContext,
488        params: &JsonObject,
489    ) -> Result<JsonObject, DispatchError> {
490        Self::require(ctx, Feature::Prompts)?;
491        let name = string_param(params, "name")?;
492        let arguments = match parsed_param(params, "arguments")? {
493            None | Some(serde_json::Value::Null) => JsonObject::default(),
494            Some(arguments) => {
495                JsonObject::from_value(arguments).map_err(|_| DispatchError::MalformedParams)?
496            }
497        };
498        let getter = self
499            .server
500            .getter(&name)
501            .ok_or(DispatchError::UnknownPrompt)?;
502        getter(ctx.clone(), arguments).await
503    }
504
505    /// Serves `logging/setLevel`.
506    ///
507    /// Answers the empty object the method's own result type is defined as,
508    /// after checking the requested `level` is one the spec names. The check
509    /// is here rather than ignored because a server that accepts any string
510    /// reports a level it cannot honour: the client believes a filter is in
511    /// place and stops sending the records it would otherwise have expected,
512    /// and the peer has no way to tell "set to debug" from "set to a typo".
513    ///
514    /// The level is stored on the session's [`LogGate`], which every later
515    /// request's context shares, so [`RequestContext::log`] filters against
516    /// it from the next record on.
517    ///
518    /// [`LogGate`]: crate::application::server::LogGate
519    fn set_log_level(
520        ctx: &RequestContext,
521        params: &JsonObject,
522    ) -> Result<JsonObject, DispatchError> {
523        Self::require(ctx, Feature::Logging)?;
524        let level = string_param(params, "level")?;
525        // A `level` outside the spec's own set is a client error, and answers
526        // `InvalidParams` — the same answer as a missing member, because a
527        // peer cannot act on either and the difference is not one it can use.
528        let level = level
529            .parse::<LogLevel>()
530            .map_err(|_| DispatchError::MalformedParams)?;
531        ctx.peer().log_gate().set(level);
532        Ok(JsonObject::default())
533    }
534
535    /// Serves `completion/complete`.
536    ///
537    /// Returns an empty `values` array: the crate registers no completable
538    /// `PromptArgument` values, so the reference is well-defined — there is
539    /// nothing to offer, and the suite documents an empty array as a
540    /// conforming answer.
541    ///
542    /// `completion` is an object holding `values`, not a bare array. An empty
543    /// *array* at the top level answers `{"completion":[]}`, which is the
544    /// shape a first reading of the method name suggests and the one that
545    /// scores `0 passed, 2 failed` — the scenario reads
546    /// `r.completion.values`, finds `[]` has no member `values`, and reports
547    /// `Missing values array in completion`. The empty case is the one where
548    /// the distinction is invisible: both a missing `values` and an empty one
549    /// mean "no completions", so the error names a missing array that was
550    /// never absent, only wrapped wrongly.
551    fn complete(ctx: &RequestContext, _params: &JsonObject) -> Result<JsonObject, DispatchError> {
552        Self::require(ctx, Feature::Completions)?;
553        JsonObject::new(r#"{"completion":{"values":[]}}"#)
554            .map_err(|_| DispatchError::MalformedParams)
555    }
556
557    /// Answers the handshake: this server, the revision it speaks, and what it
558    /// can do.
559    ///
560    /// The result is the IR form of `InitializeResult` — `protocolVersion`,
561    /// `capabilities`, `serverInfo` — which are the three fields every
562    /// revision that has a handshake marks required. `instructions` is omitted
563    /// rather than emitted empty: it is optional, it is a human-facing hint
564    /// with no source on a [`Server`], and an empty string is worse than
565    /// absent, because a client that reads it has been told the server has no
566    /// instructions when what it means is that this server supplies none.
567    ///
568    /// # What decides the revision
569    ///
570    /// The session's, not the request's. `params.protocolVersion` is what the
571    /// client *offered*; by the time a request reaches a dispatcher the layer
572    /// that owns negotiation has already settled the revision, and the session
573    /// is running on it. Answering with the offer instead would let a client
574    /// move a session onto a revision the transport never negotiated for it,
575    /// and the profile gating every other method would be one revision ahead of
576    /// the wire.
577    ///
578    /// The offer is still read and still checked, for the two reasons a
579    /// malformed handshake matters. The version the session speaks is settled
580    /// by [`negotiate_server`] against **the client's own offer**, which is
581    /// why [`check_initialize_params`] returns it rather than discarding it.
582    ///
583    /// # What decides `capabilities`
584    ///
585    /// [`FeatureProfile::advertised_by_server`] — the profile, read back. The
586    /// advertisement is a fold over the same table every feature gate is a fold
587    /// over, so it cannot claim a capability the session will not act on, and
588    /// it cannot decline one it will.
589    ///
590    /// # Errors
591    ///
592    /// - [`DispatchError::UnnegotiableVersion`] when the session is on a
593    ///   revision this build has no wire types for.
594    /// - [`DispatchError::UnknownMethod`] when the session's revision has no
595    ///   handshake at all — `2026-07-28` and `draft` removed `initialize` and
596    ///   its result, so a request for it on one of those revisions is asking
597    ///   for a method the revision does not define.
598    /// - [`DispatchError::MalformedInitialize`] when `params` is not an object
599    ///   with a string `protocolVersion` that parses, a `capabilities` object,
600    ///   and a `clientInfo` carrying a `name`.
601    fn initialize(
602        &self,
603        ctx: &RequestContext,
604        params: &JsonObject,
605    ) -> Result<JsonObject, DispatchError> {
606        let (version, profile) = negotiate(ctx, params)?;
607        self.initialize_result(version, profile)
608    }
609
610    /// One of the four listing methods.
611    ///
612    /// All four are the same operation against a different kind, so they are
613    /// one function with the feature and the key as parameters rather than
614    /// four near-identical bodies.
615    fn list(
616        &self,
617        ctx: &RequestContext,
618        kind: PrimitiveKind,
619        key: &str,
620    ) -> Result<JsonObject, DispatchError> {
621        Self::require(ctx, kind.feature())?;
622        let listing =
623            self.server
624                .registry()
625                .list_visible(ctx.peer().auth(), &ctx.peer().profile(), kind);
626        Ok(stamp_result(
627            wrap_listing(key, &listing),
628            Cacheable::Yes,
629            ctx.peer().negotiated(),
630        ))
631    }
632
633    /// `tools/call`, through the server's own dispatch.
634    /// Runs one `tools/call`, at most once per request id.
635    ///
636    /// The order here is not a style choice; it is the claim:
637    ///
638    /// 1. **Check.** Claim the key before anything runs. Claiming after would
639    ///    leave a window in which two callers both see an absent key.
640    /// 2. **Execute.** Only the caller that was handed the token runs the
641    ///    tool. A caller that finds a completed key answers from the recording
642    ///    and never reaches the handler, and one that finds an in-flight key
643    ///    is refused rather than queued — see [`DispatchError::DuplicateInFlight`].
644    /// 3. **Persist.** Record the result, or release the claim, before
645    ///    returning. A result that is returned but not recorded is a result a
646    ///    retry will not find, and a key that is neither recorded nor released
647    ///    is a key nobody can ever claim again.
648    ///
649    /// # Why a failed call releases rather than records
650    ///
651    /// An `Err` here means the tool produced no result — the gate refused it,
652    /// or the handler failed. There is nothing to replay, so recording it would
653    /// hand every future retry the same refusal for a condition that may have
654    /// been fixed, and would make a transient failure permanent. Releasing
655    /// makes the next attempt a fresh one, which is what a caller sending the
656    /// same id again is asking for.
657    ///
658    /// The consequence is that a tool which *ran* and then failed is retried.
659    /// That is the honest answer: this layer cannot tell a tool that had no
660    /// effect from one that charged a card and then failed to answer, and
661    /// guessing "no effect" would silently drop work. A tool that must not
662    /// run twice needs a key of its own — which is the tool's own port, not
663    /// this one.
664    ///
665    /// # What a dropped connection costs
666    ///
667    /// A client whose POST never got an answer retries with the same request
668    /// id. It gets the recorded result, and the tool ran once. This is the
669    /// only reason the store is on `Server` rather than left to the transport:
670    /// the transport sees a dropped socket and knows nothing about what the
671    /// tool did, and only the layer that ran it can answer.
672    async fn call_tool(
673        &self,
674        ctx: &RequestContext,
675        params: &JsonObject,
676    ) -> Result<JsonObject, DispatchError> {
677        Self::require(ctx, Feature::Tools)?;
678        let (name, arguments) = split_call_params(params)?;
679
680        // A request id is only unique within a session — every client numbers
681        // its first request `1` — so the key carries both. Keying on the id
682        // alone would answer one session's call with another's result.
683        //
684        // It also has to carry *what* was asked. A request id is a label for
685        // one request, not a promise that the same id always names the same
686        // operation: JSON-RPC 2.0 lets a peer reuse ids across a sequence, and
687        // the conformance suite's client restarts its counter per scenario
688        // against a connection it never closes. Keyed on the id alone, the
689        // second `tools/call` a session receives is answered with the first
690        // one's recording — a valid `CallToolResult` carrying another tool's
691        // output, which the caller has no way to detect. Repeating the *same*
692        // tool still replays, which is the case a retry needs.
693        let key = IdempotencyKey::for_method_call(
694            &ctx.peer().session(),
695            ctx.request_id(),
696            "tools/call",
697            &name,
698        );
699        let token = match self.server.idempotency().claim(&key)? {
700            // Recorded already. The answer is the recording, and the tool does
701            // not run.
702            Claim::Completed(recorded) => return Ok(recorded),
703            // Someone else holds it. Refusing is what keeps one side effect
704            // from running twice; the holder is a real request.
705            Claim::InFlight => {
706                return Err(DispatchError::DuplicateInFlight(
707                    ctx.request_id().to_string(),
708                ));
709            }
710            Claim::Claimed { token } => token,
711        };
712
713        let outcome = self.server.call(ctx, &name, arguments).await;
714        match outcome {
715            Ok(result) => {
716                // The result is both the answer and the recording, and
717                // `complete` takes it by value, so it is cloned out. `JsonObject`
718                // holds its bytes in an `Arc`, so this is a pointer bump and
719                // not a copy of the document.
720                self.server.idempotency().complete(&token, result.clone())?;
721                Ok(result)
722            }
723            Err(error) => {
724                // Release before returning, so the key is claimable again. A
725                // failed record or release is not swallowed: it is reported,
726                // because a key stuck in `Claimed` is invisible to every later
727                // caller and looks exactly like a server that stopped working.
728                self.server.idempotency().abandon(&token)?;
729                Err(error)
730            }
731        }
732    }
733
734    /// The gate every method shares, in one place.
735    ///
736    /// Separate from `RequestContext`'s own private `require`, which returns a
737    /// `GatedError` naming the caller. A method is not a tool, so the refusal
738    /// here is a `DispatchError` and the two types do not merge — a caller
739    /// matching on the distinction can tell "you may not call this method"
740    /// from "this tool needs something you lack".
741    ///
742    /// An associated function rather than a method because it reads nothing
743    /// from the dispatcher: it asks the session, and a gate that could also
744    /// consult the server is a gate with a second opinion.
745    fn require(ctx: &RequestContext, feature: Feature) -> Result<(), DispatchError> {
746        if ctx.peer().allows(feature) {
747            return Ok(());
748        }
749        Err(DispatchError::NotCallable)
750    }
751}
752
753/// Checks that `initialize` params are the shape the handshake has to be.
754///
755/// Every revision that has a handshake marks `capabilities`, `clientInfo` and
756/// `protocolVersion` required on `InitializeRequestParams`, and `Implementation`
757/// marks `name` and `version` required inside `clientInfo`. Each is read and
758/// type-checked here, which is what "not a silently-ignored field" means: a
759/// missing or misshapen required field is a refusal, not a default.
760///
761/// # Why this returns nothing
762///
763/// A check, not a parse, and the distinction is worth stating because the
764/// crate's rule is to parse rather than validate. It returns nothing because
765/// nothing in the answer comes from the request: `protocolVersion` is the
766/// session's revision, `capabilities` is the session's profile, and
767/// `serverInfo` is this server. The offer is a statement about the *client*,
768/// and the layer that owns that is the one that negotiated the session — it
769/// already holds the client's capability set and, on a `NegotiatedSession`,
770/// the client's [`PeerInfo`]. Reading it again here to build a value nobody
771/// consumes would be the second source of truth, not the first.
772///
773/// The one field whose parse *is* a decision: `protocolVersion` is run through
774/// [`ProtocolVersion`]'s `FromStr`, so a client offering a revision this build
775/// has never heard of — which the specification explicitly allows, and which
776/// [`negotiate_server`] answers with the server's own version rather than an
777/// error — still gets a session, while a string that is not a `YYYY-MM-DD`
778/// date is a typo and is refused.
779///
780/// # Errors
781///
782/// Reads `name` and `arguments` out of a `tools/call` params object.
783///
784/// A missing `name` is a malformed request rather than an unknown tool: the
785/// call named nothing at all, and reporting `UnknownTool` for it would send a
786/// caller hunting for a tool that exists and works.
787///
788/// # Errors
789///
790/// [`DispatchError::MalformedParams`] when the body is not an object, when
791/// `name` is absent or is not a string, and when `arguments` is present but is
792/// not an object. `arguments` is optional — a tool taking no parameters is
793/// called with none.
794fn split_call_params(params: &JsonObject) -> Result<(String, JsonObject), DispatchError> {
795    let parsed: serde_json::Value =
796        serde_json::from_str(params.as_str()).map_err(|_| DispatchError::MalformedParams)?;
797    let object = parsed.as_object().ok_or(DispatchError::MalformedParams)?;
798
799    let name = object
800        .get("name")
801        .and_then(serde_json::Value::as_str)
802        .ok_or(DispatchError::MalformedParams)?
803        .to_string();
804
805    let arguments = match object.get("arguments") {
806        None | Some(serde_json::Value::Null) => JsonObject::default(),
807        Some(arguments) => {
808            JsonObject::from_value(arguments.clone()).map_err(|_| DispatchError::MalformedParams)?
809        }
810    };
811
812    Ok((name, arguments))
813}
814
815/// The string member `key` of a params object.
816fn string_param(params: &JsonObject, key: &str) -> Result<String, DispatchError> {
817    parsed_param(params, key)?
818        .and_then(|value| value.as_str().map(str::to_string))
819        .ok_or(DispatchError::MalformedParams)
820}
821
822/// One member of a params object, already parsed.
823///
824/// `None` for an absent member as well as `null`, because both mean the same
825/// thing to every caller here and the specification's params are optional
826/// throughout. A non-object params document is malformed either way.
827fn parsed_param(
828    params: &JsonObject,
829    key: &str,
830) -> Result<Option<serde_json::Value>, DispatchError> {
831    let parsed: serde_json::Value =
832        serde_json::from_str(params.as_str()).map_err(|_| DispatchError::MalformedParams)?;
833    let object = parsed.as_object().ok_or(DispatchError::MalformedParams)?;
834    Ok(object.get(key).cloned())
835}
836
837/// Wraps a listing's visible entries under `key`.
838///
839/// The document is assembled from values this module built, so the only way it
840/// could be invalid is a bug here. It falls back to `{}` rather than
841/// propagating because a listing that cannot be encoded should read as empty
842/// to the caller rather than take the whole response down.
843/// Whether a result carries SEP-2549's caching hints.
844///
845/// A second parameter to [`stamp_result`] rather than a flag on every method:
846/// the schema draws the line itself, and this names which side a result is on.
847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848enum Cacheable {
849    /// `ttlMs` and `cacheScope` belong on this result.
850    Yes,
851    /// They do not — a `CallToolResult` is not something to cache.
852    No,
853}
854
855/// Adds the members **2026-07-28** requires on a result.
856///
857/// Two SEPs, one place:
858///
859/// - `resultType` is required on *every* result at this revision. Every
860///   definition in the vendored schema that ends in `Result` lists it, so a
861///   result without one is schema-invalid rather than merely dated.
862/// - `ttlMs` and `cacheScope` are required only on the cacheable ones —
863///   the four listings and `resources/read`. A `CallToolResult` carrying a
864///   `ttlMs` would be telling the client to reuse a side effect.
865///
866/// Older revisions are untouched: both members are absent from every
867/// pre-2026 result definition, and adding them would make this crate's own
868/// answer invalid on the four revisions it already passes.
869///
870/// `ttlMs` is `0` and `cacheScope` is `private` for the same reasons as in
871/// [`Dispatcher::discover`]: this server holds no cache, so the only lifetime
872/// it can honour is "not at all", and the answer describes a registry whose
873/// contents can change under a caller, which is not shareable across
874/// authorization contexts.
875fn stamp_result(result: JsonObject, cacheable: Cacheable, version: ProtocolVersion) -> JsonObject {
876    if !result_envelope_defined_in(version) {
877        return result;
878    }
879    let mut merged: serde_json::Map<String, serde_json::Value> =
880        match serde_json::from_str(result.as_str()) {
881            Ok(serde_json::Value::Object(members)) => members,
882            _ => return result,
883        };
884    // A result that already names its type keeps it: an interim
885    // `InputRequiredResult` says `"input_required"`, and overwriting that with
886    // `"complete"` would tell the client the request finished.
887    merged
888        .entry("resultType")
889        .or_insert_with(|| serde_json::Value::from("complete"));
890    if cacheable == Cacheable::Yes {
891        merged.insert("ttlMs".to_owned(), serde_json::Value::from(0));
892        merged.insert("cacheScope".to_owned(), serde_json::Value::from("private"));
893    }
894    JsonObject::from_value(serde_json::Value::Object(merged)).unwrap_or(result)
895}
896
897/// Whether `method` may answer with a SEP-2322 `InputRequiredResult`.
898///
899/// `tasks/result` is the fourth the SEP names; it belongs to the tasks
900/// extension, which this crate does not serve.
901fn accepts_input_round(method: &str) -> bool {
902    matches!(method, "tools/call" | "prompts/get" | "resources/read")
903}
904
905/// Drops members whose value is `null`.
906///
907/// Every optional member of a listing entry is typed as a string, a boolean or
908/// an object in every revision's schema, and none admits `null`. Writing an
909/// absent description as `"description": null` is therefore a schema-invalid
910/// entry, and a client that validates its input — the `2026-07-28` reference
911/// client does — refuses the whole listing over it. Only the top level is
912/// touched: a tool's `inputSchema` is the tool author's document, and `null`
913/// is a legitimate value inside one.
914fn without_nulls(mut value: serde_json::Value) -> serde_json::Value {
915    if let Some(members) = value.as_object_mut() {
916        members.retain(|_, member| !member.is_null());
917    }
918    value
919}
920
921/// Whether SEP-2549's result envelope exists in this revision.
922#[must_use]
923pub const fn result_envelope_defined_in(version: ProtocolVersion) -> bool {
924    matches!(
925        version,
926        ProtocolVersion::V2026_07_28 | ProtocolVersion::Unknown(_)
927    )
928}
929
930fn wrap_listing(key: &str, listing: &Listing) -> JsonObject {
931    let entries: Vec<serde_json::Value> = listing
932        .visible()
933        .iter()
934        .map(|entry| without_nulls(listing_entry(entry)))
935        .collect();
936    JsonObject::from_value(serde_json::json!({ key: entries })).unwrap_or_default()
937}
938
939/// The common-denominator JSON for one registered primitive.
940///
941/// The match is exhaustive, which is the whole point: a new `Primitive` variant
942/// fails to compile in this function rather than silently vanishing from every
943/// listing. That is what the exhaustiveness buys, and it needs no `Option` to
944/// buy it — an `Option` here would only invite a `filter_map` to swallow a
945/// future `None` quietly, undoing the check.
946fn listing_entry(registered: &Registered) -> serde_json::Value {
947    match registered.primitive() {
948        Primitive::Tool(tool) => {
949            let mut entry = serde_json::json!({
950                "name": tool.name.as_str(),
951                "description": tool.description,
952            });
953            if let (serde_json::Value::Object(map), Ok(schema)) = (
954                &mut entry,
955                serde_json::from_str::<serde_json::Value>(tool.input_schema.as_str()),
956            ) {
957                // Embedded as a value, not as a string. `json!` will not do
958                // this for us: `"inputSchema": text.as_str()` produces a JSON
959                // *string* whose characters happen to be JSON, so every peer
960                // would read a schema where the specification says an object,
961                // and the failure would surface in the client as a tool with
962                // no arguments rather than here.
963                map.insert("inputSchema".to_string(), schema);
964            }
965            entry
966        }
967        Primitive::Resource(resource) => serde_json::json!({
968            "uri": resource.uri.as_str(),
969            "name": resource.name,
970            "description": resource.description,
971            "mimeType": resource.mime_type,
972        }),
973        Primitive::ResourceTemplate(template) => serde_json::json!({
974            "uriTemplate": template.uri_template.as_str(),
975            "name": template.name,
976            "description": template.description,
977            "mimeType": template.mime_type,
978        }),
979        Primitive::Prompt(prompt) => serde_json::json!({
980            "name": prompt.name.as_str(),
981            "description": prompt.description,
982            "arguments": prompt.arguments.iter().map(prompt_argument).collect::<Vec<_>>(),
983        }),
984    }
985}
986
987/// One prompt argument, as JSON.
988///
989/// Written out rather than derived, because `PromptArgument` is a domain type
990/// with no `Serialize`: it exists to be lowered by a codec, and deriving
991/// `Serialize` on it here would make the wire shape the domain's own opinion
992/// instead of the codec's. `title` is skipped for the same reason the
993/// other listings skip it — `2026-07-28` only.
994fn prompt_argument(argument: &PromptArgument) -> serde_json::Value {
995    without_nulls(serde_json::json!({
996        "name": argument.name,
997        "description": argument.description,
998        "required": argument.required,
999    }))
1000}
1001
1002/// The revision and profile `initialize` settles on with this peer.
1003///
1004/// Resolved for the revision that was *negotiated*, from the capabilities
1005/// the client actually sent, rather than read off the session. The session
1006/// was built before any frame arrived and knows nothing about the peer;
1007/// resolving from it would tell a `2024-11-05` client it had capabilities
1008/// that revision cannot express, which is the one thing a handshake must
1009/// never do.
1010///
1011/// Free rather than a method: what a revision can express does not depend
1012/// on which [`Server`] is serving it, and a `&self` here would imply a
1013/// per-server rule that does not exist.
1014fn negotiate(
1015    ctx: &RequestContext,
1016    params: &JsonObject,
1017) -> Result<(ProtocolVersion, FeatureProfile), DispatchError> {
1018    let transport = ctx.peer().transport();
1019    let supported = supported_on(transport)?;
1020    let offer = read_offer(params).map_err(|_| DispatchError::MalformedInitialize)?;
1021    let version = negotiate_server(offer.version, &supported).version();
1022    let profile = resolve(Negotiation {
1023        version,
1024        client_caps: offer.capabilities,
1025        server_caps: CapabilitySet::all_for(version),
1026        transport,
1027    });
1028    Ok((version, profile))
1029}
1030
1031/// The revisions this server will agree to over `transport`.
1032///
1033/// HTTP+SSE belongs to `2024-11-05` alone, so a legacy session is never
1034/// answered with a revision that does not define it: a client that offers
1035/// `2025-11-25` over it is counter-offered `2024-11-05` and decides for itself
1036/// whether it can continue. Other transports answer with whatever the client
1037/// offered when this build speaks it.
1038fn supported_on(transport: TransportKind) -> Result<SupportedVersions, DispatchError> {
1039    let all = SupportedVersions::all().map_err(|_| DispatchError::UnnegotiableVersion)?;
1040    if transport != TransportKind::LegacySse {
1041        return Ok(all);
1042    }
1043    let defining: Vec<ProtocolVersion> = all
1044        .as_slice()
1045        .iter()
1046        .copied()
1047        .filter(|version| transport_allowed(*version, transport))
1048        .collect();
1049    SupportedVersions::from_slice(&defining).map_err(|_| DispatchError::UnnegotiableVersion)
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use std::num::NonZeroUsize;
1055    use std::sync::Arc;
1056    use std::sync::atomic::{AtomicUsize, Ordering};
1057
1058    use super::{DispatchError, Dispatcher};
1059    use crate::application::server::context::{
1060        CallbackExchange, ExchangeError, PeerSession, RequestContext,
1061    };
1062    use crate::application::server::{Server, ToolHandler};
1063    use crate::application::session::{FrameSink, SinkError};
1064    use crate::domain::feature::{Negotiation, resolve};
1065    use crate::domain::ir::{JsonObject, JsonObjectError};
1066    use crate::domain::registry::{AuthContext, Primitive, Registered, Scope, ScopeSet, Tool};
1067    use crate::domain::session::{CancellationToken, SessionId};
1068    use crate::domain::{Capability, CapabilitySet, ProtocolVersion, RequestId, TransportKind};
1069
1070    /// What a test here can fail with.
1071    #[derive(Debug)]
1072    enum Failure {
1073        /// The test's own complaint.
1074        Message(String),
1075        /// A fixture was not a JSON object.
1076        Fixture(JsonObjectError),
1077        /// A result the dispatcher built did not read back as JSON.
1078        Result(serde_json::Error),
1079    }
1080
1081    impl core::fmt::Display for Failure {
1082        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1083            match self {
1084                Self::Message(text) => f.write_str(text),
1085                Self::Fixture(error) => write!(f, "bad fixture: {error}"),
1086                Self::Result(error) => write!(f, "unreadable result: {error}"),
1087            }
1088        }
1089    }
1090
1091    impl std::error::Error for Failure {}
1092
1093    impl From<String> for Failure {
1094        fn from(text: String) -> Self {
1095            Self::Message(text)
1096        }
1097    }
1098
1099    impl From<&str> for Failure {
1100        fn from(text: &str) -> Self {
1101            Self::Message(text.to_string())
1102        }
1103    }
1104
1105    impl From<JsonObjectError> for Failure {
1106        fn from(error: JsonObjectError) -> Self {
1107            Self::Fixture(error)
1108        }
1109    }
1110
1111    impl From<DispatchError> for Failure {
1112        fn from(error: DispatchError) -> Self {
1113            Self::Message(error.to_string())
1114        }
1115    }
1116
1117    impl From<serde_json::Error> for Failure {
1118        fn from(error: serde_json::Error) -> Self {
1119            Self::Result(error)
1120        }
1121    }
1122
1123    type TestError = Failure;
1124
1125    /// The test's own complaint.
1126    ///
1127    /// A helper rather than `Err("...".into())` at each site, because that
1128    /// spelling settles the module's error type on `&str` — which converts to
1129    /// both itself and `Box<dyn Error>`, so the compiler picks it from the
1130    /// first early return and every `?` in the body then has no valid
1131    /// conversion. A vague error type is a compile error that points at the
1132    /// `?` and not at the `Err` that caused it.
1133    fn fail(message: impl Into<String>) -> TestError {
1134        Failure::Message(message.into())
1135    }
1136
1137    /// An exchange that refuses, so no test here can accidentally pass by
1138    /// having a callback answered.
1139    struct NoExchange;
1140
1141    impl CallbackExchange for NoExchange {
1142        fn exchange(
1143            &self,
1144            method: &str,
1145            _params: &JsonObject,
1146        ) -> impl std::future::Future<Output = Result<JsonObject, ExchangeError>> + Send {
1147            let _ = method;
1148            std::future::ready(Err(ExchangeError::new("no callbacks here")))
1149        }
1150    }
1151
1152    /// A sink that discards.
1153    struct NoSink;
1154
1155    impl FrameSink for NoSink {
1156        fn send(
1157            &self,
1158            _frame: &str,
1159        ) -> impl std::future::Future<Output = Result<(), SinkError>> + Send {
1160            std::future::ready(Ok(()))
1161        }
1162    }
1163
1164    /// A handler that answers with `answer`.
1165    fn answering(answer: &'static str) -> ToolHandler {
1166        let answer: JsonObject = JsonObject::new(answer).unwrap_or_default();
1167        Arc::new(move |_ctx, _args| {
1168            let answer = answer.clone();
1169            Box::pin(async move { answer })
1170        })
1171    }
1172
1173    /// A dispatcher over one plain tool and one scope-restricted tool.
1174    fn dispatcher() -> Result<Dispatcher, TestError> {
1175        let restricted = Registered::from(Primitive::from(Tool::new(
1176            "secret",
1177            r#"{"type":"object"}"#,
1178        )?))
1179        .requiring_scope(Scope::Read);
1180
1181        Ok(Dispatcher::new(Arc::new(
1182            Server::builder("test")
1183                .tool(Tool::new("echo", r#"{"type":"object"}"#)?, answering("{}"))
1184                .restricted_tool(restricted, answering("{}"))
1185                .build(),
1186        )))
1187    }
1188
1189    /// A session at `version` with `scopes`, negotiating `server_caps`.
1190    fn peer(version: ProtocolVersion, scopes: ScopeSet, server_caps: CapabilitySet) -> PeerSession {
1191        PeerSession::new(
1192            version,
1193            resolve(Negotiation {
1194                version,
1195                client_caps: CapabilitySet::empty(),
1196                server_caps,
1197                transport: TransportKind::StreamableHttp,
1198            }),
1199            SessionId::new(1),
1200            TransportKind::Stdio,
1201            Arc::new(NoExchange),
1202            Arc::new(NoSink),
1203        )
1204        .with_auth(AuthContext::new("tester", scopes))
1205    }
1206
1207    /// A request on `peer`.
1208    fn ctx(peer: &PeerSession) -> RequestContext {
1209        RequestContext::for_request(peer, RequestId::text("r1"), CancellationToken::new(), None)
1210    }
1211
1212    /// The server capabilities a fully-featured session negotiates at
1213    /// `version`.
1214    fn caps(version: ProtocolVersion) -> CapabilitySet {
1215        CapabilitySet::all_for(version)
1216    }
1217
1218    /// A session on session `id`, so a test can hold two at once.
1219    ///
1220    /// The existing [`peer`] pins session 1, which is fine until a test needs
1221    /// to prove that two sessions are not the same session.
1222    fn peer_on(
1223        version: ProtocolVersion,
1224        scopes: ScopeSet,
1225        server_caps: CapabilitySet,
1226        id: u64,
1227    ) -> PeerSession {
1228        PeerSession::new(
1229            version,
1230            resolve(Negotiation {
1231                version,
1232                client_caps: CapabilitySet::empty(),
1233                server_caps,
1234                transport: TransportKind::StreamableHttp,
1235            }),
1236            SessionId::new(id),
1237            TransportKind::Stdio,
1238            Arc::new(NoExchange),
1239            Arc::new(NoSink),
1240        )
1241        .with_auth(AuthContext::new("tester", scopes))
1242    }
1243
1244    /// The one tool the idempotency tests call.
1245    fn charge_tool() -> Result<Tool, TestError> {
1246        Ok(Tool::new("charge", r#"{"type":"object"}"#)?)
1247    }
1248
1249    /// `initialize` params for a client that offers `version`.
1250    fn init_params(version: &str) -> Result<JsonObject, TestError> {
1251        Ok(JsonObject::new(
1252            r#"{"protocolVersion":""#.to_string()
1253                + version
1254                + r#"","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}"#,
1255        )?)
1256    }
1257
1258    /// An `initialize` body whose client advertises `capabilities`.
1259    fn init_params_with(version: &str, capabilities: &str) -> Result<JsonObject, TestError> {
1260        Ok(JsonObject::new(
1261            r#"{"protocolVersion":""#.to_string()
1262                + version
1263                + r#"","capabilities":"#
1264                + capabilities
1265                + r#","clientInfo":{"name":"probe","version":"0"}}"#,
1266        )?)
1267    }
1268
1269    /// An IR object read back as a `serde_json` value.
1270    fn parsed(object: &JsonObject) -> Result<serde_json::Value, TestError> {
1271        Ok(serde_json::from_str(object.as_str())?)
1272    }
1273
1274    /// The advertised capabilities are resolved for the revision that was
1275    /// negotiated, so an older client is never told it has something that
1276    /// revision cannot express.
1277    ///
1278    /// The session here is on `2025-06-18`, which is the newest revision in
1279    /// this build's profile that gates `elicitation`. The client offers
1280    /// `2024-11-05`, which has no such capability. Reading the profile off the
1281    /// session — as this did — would have answered `elicitation: {}` to a
1282    /// client on a revision whose schema has nowhere to put it.
1283    #[tokio::test]
1284    async fn advertised_capabilities_follow_the_negotiated_revision() -> Result<(), TestError> {
1285        let session_version = ProtocolVersion::V2025_06_18;
1286        let peer = peer(
1287            session_version,
1288            ScopeSet::all(),
1289            CapabilitySet::all_for(session_version),
1290        );
1291        let result = dispatcher()?
1292            .dispatch(&ctx(&peer), "initialize", &init_params("2024-11-05")?)
1293            .await
1294            .map_err(|error| Failure::Message(format!("the handshake refused: {error}")))?;
1295
1296        let capabilities = parsed(&result)?
1297            .get("capabilities")
1298            .cloned()
1299            .ok_or("the handshake advertised no capabilities")?;
1300        assert!(
1301            capabilities.get("elicitation").is_none(),
1302            "2024-11-05 has no elicitation capability: {capabilities}"
1303        );
1304        Ok(())
1305    }
1306
1307    /// A capability the client advertises narrows what the server answers it
1308    /// can do, because the profile is resolved from what the client actually
1309    /// sent rather than from an assumption that it sent everything.
1310    #[tokio::test]
1311    async fn the_client_capabilities_are_read_not_assumed() -> Result<(), TestError> {
1312        let session_version = ProtocolVersion::V2025_11_25;
1313        let peer = peer(
1314            session_version,
1315            ScopeSet::all(),
1316            CapabilitySet::all_for(session_version),
1317        );
1318        dispatcher()?
1319            .dispatch(
1320                &ctx(&peer),
1321                "initialize",
1322                &init_params_with("2025-11-25", r#"{"roots":{"listChanged":true}}"#)?,
1323            )
1324            .await
1325            .map_err(|error| Failure::Message(format!("the handshake refused: {error}")))?;
1326        Ok(())
1327    }
1328
1329    /// The handshake answers with the revision the client negotiated.
1330    ///
1331    /// The distinction is the test's whole point: a client offering a version
1332    /// this build cannot speak has already been negotiated down by the time a
1333    /// request reaches here, so echoing the *request* would tell the peer it
1334    /// got the revision it asked for when it did not.
1335    #[tokio::test]
1336    async fn initialize_reports_the_negotiated_revision() -> Result<(), TestError> {
1337        let version = ProtocolVersion::V2025_06_18;
1338        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1339        // The session is on 2025-06-18 and the client offers 2024-11-05. The
1340        // answer must be the client's, not the session's: the session was
1341        // constructed before any frame arrived and knows nothing about who is
1342        // calling. Negotiating against it would have every client answered with
1343        // whatever revision the process happened to be built for.
1344        let result = dispatcher()?
1345            .dispatch(&ctx(&peer), "initialize", &init_params("2024-11-05")?)
1346            .await
1347            .map_err(|error| Failure::Message(format!("the handshake refused: {error}")))?;
1348
1349        let reported = parsed(&result)?
1350            .get("protocolVersion")
1351            .and_then(serde_json::Value::as_str)
1352            .ok_or("the handshake did not report a revision")?
1353            .to_string();
1354        assert_eq!(reported, "2024-11-05");
1355        Ok(())
1356    }
1357
1358    /// A client newer than this build gets a session on the newest revision
1359    /// whose handshake exists, rather than a refusal.
1360    ///
1361    /// This is the whole point of reading the offer. A build that answered from
1362    /// its own pinned version could not tell this client from any other; one
1363    /// that counter-offered `2026-07-28` would name a revision with no
1364    /// `initialize` on it, so the peer would be told to use a protocol on which
1365    /// the handshake it just attempted does not exist.
1366    #[tokio::test]
1367    async fn a_client_newer_than_this_build_gets_the_newest_handshake() -> Result<(), TestError> {
1368        let version = ProtocolVersion::V2025_06_18;
1369        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1370        let result = dispatcher()?
1371            .dispatch(&ctx(&peer), "initialize", &init_params("2099-01-01")?)
1372            .await
1373            .map_err(|error| Failure::Message(format!("a newer client was refused: {error}")))?;
1374
1375        let reported = parsed(&result)?
1376            .get("protocolVersion")
1377            .and_then(serde_json::Value::as_str)
1378            .ok_or("the handshake did not report a revision")?
1379            .to_string();
1380        assert_eq!(
1381            reported,
1382            ProtocolVersion::V2025_11_25.to_string(),
1383            "the newest revision this build can actually complete a handshake on"
1384        );
1385        Ok(())
1386    }
1387
1388    /// The reported capabilities are the features the profile allows, not a
1389    /// second list kept beside it.
1390    ///
1391    /// The server here declared one tool and negotiated everything, so `tools`
1392    /// is present. What must *not* appear is anything client-side: a
1393    /// `ServerCapabilities` object has no field for `roots` or `sampling`, and
1394    /// a handshake that grew one would be promising a field the peer's schema
1395    /// has no place to read.
1396    #[tokio::test]
1397    async fn the_handshake_advertises_only_server_capabilities() -> Result<(), TestError> {
1398        let version = ProtocolVersion::V2025_06_18;
1399        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1400        let result = dispatcher()?
1401            .dispatch(&ctx(&peer), "initialize", &init_params("2025-06-18")?)
1402            .await
1403            .map_err(|error| Failure::Message(format!("the handshake refused: {error}")))?;
1404
1405        let capabilities = parsed(&result)?
1406            .get("capabilities")
1407            .cloned()
1408            .ok_or("the handshake advertised no capabilities")?;
1409        for forbidden in ["roots", "sampling", "elicitation"] {
1410            assert!(
1411                capabilities.get(forbidden).is_none(),
1412                "a client-side capability reached the server advertisement: {forbidden}"
1413            );
1414        }
1415        assert!(
1416            capabilities.get("tools").is_some(),
1417            "a session that negotiated tools did not advertise them"
1418        );
1419        Ok(())
1420    }
1421
1422    /// The handshake is what establishes the session, so it answers on a peer
1423    /// that negotiated no tools at all.
1424    ///
1425    /// Gating it the way every other method is gated would mean a client
1426    /// could never start a conversation, because a session begins with
1427    /// capabilities rather than with any.
1428    #[tokio::test]
1429    async fn initialize_answers_on_a_session_with_no_features() -> Result<(), TestError> {
1430        let version = ProtocolVersion::V2025_06_18;
1431        let peer = peer(version, ScopeSet::all(), CapabilitySet::empty());
1432        let outcome = dispatcher()?
1433            .dispatch(&ctx(&peer), "initialize", &init_params("2025-06-18")?)
1434            .await;
1435
1436        assert!(outcome.is_ok(), "the handshake was gated off: {outcome:?}");
1437        Ok(())
1438    }
1439
1440    /// Every refusal below is a body a client could not have meant, and they
1441    /// are one error rather than three: a caller that distinguishes "no
1442    /// `clientInfo`" from "no `name` inside it" is distinguishing two spellings
1443    /// of the same mistake.
1444    #[tokio::test]
1445    async fn a_malformed_handshake_is_refused_whatever_is_missing() -> Result<(), TestError> {
1446        let version = ProtocolVersion::V2025_06_18;
1447        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1448
1449        let bodies = [
1450            "{}",
1451            r#"{"capabilities":{},"clientInfo":{"name":"p","version":"0"}}"#,
1452            r#"{"protocolVersion":"2025-06-18","clientInfo":{"name":"p","version":"0"}}"#,
1453            r#"{"protocolVersion":"2025-06-18","capabilities":{}}"#,
1454            r#"{"protocolVersion":"2025-06-18","capabilities":[],"clientInfo":{"name":"p"}}"#,
1455            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{}}"#,
1456            r#"{"protocolVersion":"nonsense","capabilities":{},"clientInfo":{"name":"p"}}"#,
1457        ];
1458        for body in bodies {
1459            let outcome = dispatcher()?
1460                .dispatch(&ctx(&peer), "initialize", &JsonObject::new(body)?)
1461                .await;
1462            assert_eq!(
1463                outcome,
1464                Err(DispatchError::MalformedInitialize),
1465                "this body was accepted: {body}"
1466            );
1467        }
1468        Ok(())
1469    }
1470
1471    /// SEP-2575 removed the handshake in `2026-07-28`, so a session on that
1472    /// revision refuses `initialize` rather than answering a request the
1473    /// revision has no shape for.
1474    ///
1475    /// The refusal is `UnknownMethod` and not `MalformedInitialize`: the method
1476    /// is not refused because the *body* is wrong, it is refused because on
1477    /// this revision the method does not exist, and a client that migrates its
1478    /// code to the new revision should see a different error for the same
1479    /// request than one that typos it on an old revision.
1480    #[tokio::test]
1481    async fn a_session_without_the_handshake_refuses_the_method() -> Result<(), TestError> {
1482        let version = ProtocolVersion::V2026_07_28;
1483        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1484        let outcome = dispatcher()?
1485            .dispatch(&ctx(&peer), "initialize", &init_params("2026-07-28")?)
1486            .await;
1487
1488        assert_eq!(outcome, Err(DispatchError::UnknownMethod));
1489        Ok(())
1490    }
1491
1492    /// A method no revision defines.
1493    #[tokio::test]
1494    async fn an_undefined_method_is_refused() -> Result<(), TestError> {
1495        let version = ProtocolVersion::V2025_06_18;
1496        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1497        let outcome = dispatcher()?
1498            .dispatch(&ctx(&peer), "tools/frobnicate", &JsonObject::default())
1499            .await;
1500        assert_eq!(outcome, Err(DispatchError::UnknownMethod));
1501        Ok(())
1502    }
1503
1504    /// A `tools/call` naming nothing is a malformed request, not a missing tool.
1505    ///
1506    /// The distinction is the test's whole point: `UnknownTool` would send a
1507    /// caller looking for a tool that exists and works.
1508    #[tokio::test]
1509    async fn a_call_with_no_name_is_malformed_rather_than_unknown() -> Result<(), TestError> {
1510        let version = ProtocolVersion::V2025_06_18;
1511        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1512        let params = JsonObject::new(r#"{"arguments":{}}"#)?;
1513        let outcome = dispatcher()?
1514            .dispatch(&ctx(&peer), "tools/call", &params)
1515            .await;
1516        assert_eq!(outcome, Err(DispatchError::MalformedParams));
1517        Ok(())
1518    }
1519
1520    /// `arguments` is optional, so a call with a name alone reaches the handler.
1521    #[tokio::test]
1522    async fn a_call_without_arguments_still_reaches_the_handler() -> Result<(), TestError> {
1523        let version = ProtocolVersion::V2025_06_18;
1524        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1525        let params = JsonObject::new(r#"{"name":"echo"}"#)?;
1526        let outcome = dispatcher()?
1527            .dispatch(&ctx(&peer), "tools/call", &params)
1528            .await;
1529        assert_eq!(
1530            outcome.map(|answer| answer.as_str().to_string()),
1531            Ok("{}".to_string())
1532        );
1533        Ok(())
1534    }
1535
1536    /// A method the session may not use is refused before its params are read.
1537    #[tokio::test]
1538    async fn a_method_the_session_lacks_is_refused() -> Result<(), TestError> {
1539        let version = ProtocolVersion::V2025_06_18;
1540        let peer = peer(version, ScopeSet::all(), CapabilitySet::empty());
1541        let outcome = dispatcher()?
1542            .dispatch(&ctx(&peer), "tools/list", &JsonObject::default())
1543            .await;
1544        assert_eq!(outcome, Err(DispatchError::NotCallable));
1545        Ok(())
1546    }
1547
1548    /// `server/discover` answers on `2026-07-28` and nowhere else.
1549    ///
1550    /// Both halves, because SEP-2575 defines it in the one revision that
1551    /// removed the handshake and a build that answered it everywhere would be
1552    /// offering a method no earlier spec has.
1553    ///
1554    /// The five required members are checked by name and by presence, which is
1555    /// what the schema requires and the only thing a result can be wrong about
1556    /// before anything reads its contents.
1557    #[tokio::test]
1558    async fn discover_answers_only_where_sep_2575_defines_it() -> Result<(), TestError> {
1559        let modern = ProtocolVersion::V2026_07_28;
1560        let answered = dispatcher()?
1561            .dispatch(
1562                &ctx(&peer(modern, ScopeSet::empty(), caps(modern))),
1563                "server/discover",
1564                &JsonObject::default(),
1565            )
1566            .await
1567            .map_err(|error| Failure::from(error.to_string()))?;
1568        let parsed: serde_json::Value = serde_json::from_str(answered.as_str())
1569            .map_err(|error| Failure::from(error.to_string()))?;
1570        for member in [
1571            "resultType",
1572            "supportedVersions",
1573            "ttlMs",
1574            "cacheScope",
1575            "capabilities",
1576        ] {
1577            assert!(
1578                parsed.get(member).is_some(),
1579                "DiscoverResult is missing required member `{member}`",
1580            );
1581        }
1582        assert_eq!(
1583            parsed.get("resultType").and_then(serde_json::Value::as_str),
1584            Some("complete")
1585        );
1586
1587        let older = ProtocolVersion::V2025_11_25;
1588        let refused = dispatcher()?
1589            .dispatch(
1590                &ctx(&peer(older, ScopeSet::empty(), caps(older))),
1591                "server/discover",
1592                &JsonObject::default(),
1593            )
1594            .await;
1595        assert_eq!(
1596            refused.map(|answer| answer.as_str().to_string()),
1597            Err(DispatchError::UnknownMethod),
1598            "SEP-2575 defines server/discover only from 2026-07-28",
1599        );
1600        Ok(())
1601    }
1602
1603    /// The advertised capabilities are the ones this server honours.
1604    ///
1605    /// A second list here would be a second opinion, and the suite's
1606    /// `discover-capabilities-match-handlers` check exists precisely because
1607    /// servers get that wrong: a client that discovers `prompts` and then
1608    /// finds `prompts/get` refused has been told something untrue.
1609    #[tokio::test]
1610    async fn discover_advertises_the_profile_the_server_honours() -> Result<(), TestError> {
1611        let version = ProtocolVersion::V2026_07_28;
1612        let server_caps = CapabilitySet::empty().with(Capability::Tools);
1613        let peer = peer(version, ScopeSet::empty(), server_caps);
1614        let answered = dispatcher()?
1615            .dispatch(&ctx(&peer), "server/discover", &JsonObject::default())
1616            .await
1617            .map_err(|error| Failure::from(error.to_string()))?;
1618        let parsed: serde_json::Value = serde_json::from_str(answered.as_str())
1619            .map_err(|error| Failure::from(error.to_string()))?;
1620        let capabilities = parsed
1621            .get("capabilities")
1622            .ok_or("the result carried no capabilities")?;
1623
1624        assert!(
1625            capabilities.get("tools").is_some(),
1626            "tools was negotiated and not advertised: {capabilities}",
1627        );
1628        assert!(
1629            capabilities.get("prompts").is_none(),
1630            "prompts was not negotiated and was advertised anyway: {capabilities}",
1631        );
1632        Ok(())
1633    }
1634
1635    /// SEP-2549's envelope appears at `2026-07-28` and nowhere else.
1636    ///
1637    /// Both directions, and the second is the one that matters: a build that
1638    /// stamped `resultType` everywhere would pass the first check and break the
1639    /// four revisions it already answers correctly, because `resultType`
1640    /// appears in no pre-2026 result definition.
1641    ///
1642    /// The cacheable half is checked on a listing and the non-cacheable half on
1643    /// a call, because those are exactly the two sides of [`Cacheable`] and a
1644    /// test that only listed would not notice a `ttlMs` on a side effect.
1645    #[tokio::test]
1646    async fn the_result_envelope_is_stamped_only_from_2026_07_28() -> Result<(), TestError> {
1647        let modern = ProtocolVersion::V2026_07_28;
1648        let listed = dispatcher()?
1649            .dispatch(
1650                &ctx(&peer(modern, ScopeSet::empty(), caps(modern))),
1651                "tools/list",
1652                &JsonObject::default(),
1653            )
1654            .await
1655            .map_err(|error| Failure::from(error.to_string()))?;
1656        let listed: serde_json::Value = serde_json::from_str(listed.as_str())
1657            .map_err(|error| Failure::from(error.to_string()))?;
1658        for member in ["resultType", "ttlMs", "cacheScope"] {
1659            assert!(
1660                listed.get(member).is_some(),
1661                "a cacheable result is missing `{member}`: {listed}",
1662            );
1663        }
1664
1665        let older = ProtocolVersion::V2025_11_25;
1666        let legacy = dispatcher()?
1667            .dispatch(
1668                &ctx(&peer(older, ScopeSet::empty(), caps(older))),
1669                "tools/list",
1670                &JsonObject::default(),
1671            )
1672            .await
1673            .map_err(|error| Failure::from(error.to_string()))?;
1674        let legacy: serde_json::Value = serde_json::from_str(legacy.as_str())
1675            .map_err(|error| Failure::from(error.to_string()))?;
1676        for member in ["resultType", "ttlMs", "cacheScope"] {
1677            assert!(
1678                legacy.get(member).is_none(),
1679                "`{member}` is not defined before 2026-07-28 and was stamped anyway: {legacy}",
1680            );
1681        }
1682        Ok(())
1683    }
1684
1685    /// `ping` answers on a session with nothing else, because that is what a
1686    /// liveness probe on an idle session is for.
1687    #[tokio::test]
1688    async fn ping_answers_with_no_features_at_all() -> Result<(), TestError> {
1689        let version = ProtocolVersion::V2024_11_05;
1690        let peer = peer(version, ScopeSet::empty(), CapabilitySet::empty());
1691        let outcome = dispatcher()?
1692            .dispatch(&ctx(&peer), "ping", &JsonObject::default())
1693            .await;
1694        assert_eq!(
1695            outcome.map(|answer| answer.as_str().to_string()),
1696            Ok("{}".to_string())
1697        );
1698        Ok(())
1699    }
1700
1701    /// The listing shows what the caller may see, and hides what it may not.
1702    ///
1703    /// The caller here holds **no** scope, which is the half that makes the
1704    /// assertion mean something — a filter that showed everything would pass
1705    /// the next test, and one that showed nothing would pass this one.
1706    #[tokio::test]
1707    async fn the_listing_hides_a_tool_the_caller_may_not_see() -> Result<(), TestError> {
1708        let version = ProtocolVersion::V2025_06_18;
1709        let peer = peer(version, ScopeSet::empty(), CapabilitySet::all_for(version));
1710        let listed = dispatcher()?
1711            .dispatch(&ctx(&peer), "tools/list", &JsonObject::default())
1712            .await
1713            .map_err(|error| Failure::from(error.to_string()))?;
1714        let parsed: serde_json::Value = serde_json::from_str(listed.as_str())
1715            .map_err(|error| Failure::from(error.to_string()))?;
1716        let names: Vec<&str> = parsed
1717            .get("tools")
1718            .and_then(serde_json::Value::as_array)
1719            .ok_or("the listing has no tools array")?
1720            .iter()
1721            .filter_map(|tool| tool.get("name"))
1722            .filter_map(serde_json::Value::as_str)
1723            .collect();
1724        assert_eq!(names, ["echo"]);
1725        Ok(())
1726    }
1727
1728    /// The same listing, for a caller that does hold the scope.
1729    #[tokio::test]
1730    async fn the_listing_shows_the_tool_to_a_caller_holding_its_scope() -> Result<(), TestError> {
1731        let version = ProtocolVersion::V2025_06_18;
1732        let peer = peer(version, ScopeSet::all(), CapabilitySet::all_for(version));
1733        let listed = dispatcher()?
1734            .dispatch(&ctx(&peer), "tools/list", &JsonObject::default())
1735            .await
1736            .map_err(|error| Failure::from(error.to_string()))?;
1737        assert!(
1738            listed.as_str().contains("secret"),
1739            "an unrestricted caller could not see the restricted tool: {}",
1740            listed.as_str()
1741        );
1742        Ok(())
1743    }
1744
1745    // -----------------------------------------------------------------------
1746    // A retried call runs the tool once
1747    // -----------------------------------------------------------------------
1748
1749    /// A dispatcher whose tool counts how many times it ran.
1750    ///
1751    /// A counter rather than a `Cell<bool>`: the claim is that the tool runs
1752    /// _once_, and a boolean cannot distinguish one from two. A handler that
1753    /// returned a different answer each time would also work, and counting is
1754    /// the version that says what it means.
1755    fn counting(count: &Arc<AtomicUsize>, answer: &'static str) -> Result<Dispatcher, TestError> {
1756        let answer = JsonObject::new(answer).unwrap_or_default();
1757        let seen = Arc::clone(count);
1758        let handler: ToolHandler = Arc::new(move |_ctx, _args| {
1759            let count = Arc::clone(&seen);
1760            let answer = answer.clone();
1761            Box::pin(async move {
1762                count.fetch_add(1, Ordering::SeqCst);
1763                answer
1764            })
1765        });
1766        Ok(Dispatcher::new(Arc::new(
1767            Server::builder("test")
1768                .tool(charge_tool()?, handler)
1769                .build(),
1770        )))
1771    }
1772
1773    /// The tool a dropped connection leaves behind, on a session that asks
1774    /// twice with the same request id.
1775    ///
1776    /// This is the scenario the whole store exists for. A client whose POST
1777    /// got no answer retries, because it cannot tell a dropped connection from
1778    /// a call that never arrived — and the retry must not be a second charge.
1779    #[tokio::test]
1780    async fn a_retry_after_a_dropped_connection_runs_the_tool_once() -> Result<(), TestError> {
1781        let count = Arc::new(AtomicUsize::new(0));
1782        let dispatcher = counting(&count, r#"{"charged":true}"#)?;
1783        let peer = peer(
1784            ProtocolVersion::V2025_06_18,
1785            ScopeSet::all(),
1786            caps(ProtocolVersion::V2025_06_18),
1787        );
1788        let params = JsonObject::new(r#"{"name":"charge"}"#)?;
1789
1790        // The first attempt: the server runs the tool and records the answer.
1791        // The client never sees it — the connection dropped — which is not
1792        // something this layer can observe, and is the point.
1793        let first = dispatcher
1794            .dispatch(&ctx(&peer), "tools/call", &params)
1795            .await?;
1796        // The retry, same id.
1797        let second = dispatcher
1798            .dispatch(&ctx(&peer), "tools/call", &params)
1799            .await?;
1800
1801        if count.load(Ordering::SeqCst) != 1 {
1802            return Err(fail(format!(
1803                "the tool ran {} times",
1804                count.load(Ordering::SeqCst)
1805            )));
1806        }
1807        if first != second {
1808            return Err(fail("the retry was not answered from the recording"));
1809        }
1810        Ok(())
1811    }
1812
1813    /// The same request id on a *different* session is a different call.
1814    ///
1815    /// Both sessions send id `r1`, which is what every client does — ids are
1816    /// only unique per connection. A key built from the request id alone would
1817    /// answer the second session with the first one's result, and the test
1818    /// would still be green on every other case in this module.
1819    #[tokio::test]
1820    async fn the_same_request_id_on_another_session_is_not_a_retry() -> Result<(), TestError> {
1821        let count = Arc::new(AtomicUsize::new(0));
1822        let dispatcher = counting(&count, r#"{"charged":true}"#)?;
1823        let version = ProtocolVersion::V2025_06_18;
1824        let params = JsonObject::new(r#"{"name":"charge"}"#)?;
1825
1826        let first_peer = peer(version, ScopeSet::all(), caps(version));
1827        let second_peer = peer_on(version, ScopeSet::all(), caps(version), 2);
1828
1829        let _ = dispatcher
1830            .dispatch(&ctx(&first_peer), "tools/call", &params)
1831            .await?;
1832        let _ = dispatcher
1833            .dispatch(&ctx(&second_peer), "tools/call", &params)
1834            .await?;
1835
1836        if count.load(Ordering::SeqCst) != 2 {
1837            return Err(fail(format!(
1838                "two sessions ran the tool {} time(s); the second was treated as a retry",
1839                count.load(Ordering::SeqCst)
1840            )));
1841        }
1842        Ok(())
1843    }
1844
1845    /// A store that remembers the order it was driven in.
1846    ///
1847    /// The black-box tests above prove what a retry gets; this proves the
1848    /// *sequence* that produces it, which is the thing the ordering rule is
1849    /// actually about. It is white-box on purpose: "the claim is released on
1850    /// failure" has no black-box observable here, because a tool that fails at
1851    /// the gate fails the same way whether its key was released or recorded,
1852    /// and a test that cannot tell those apart is not testing the release.
1853    #[derive(Debug, Default)]
1854    struct Journal {
1855        log: std::sync::Mutex<Vec<&'static str>>,
1856    }
1857
1858    impl Journal {
1859        fn entries(&self) -> Vec<&'static str> {
1860            self.log
1861                .lock()
1862                .unwrap_or_else(std::sync::PoisonError::into_inner)
1863                .clone()
1864        }
1865    }
1866
1867    /// A capacity for a test store.
1868    ///
1869    /// `unwrap_or` rather than `unwrap`, which the crate denies, and rather
1870    /// than a `match`, which `option_if_let_else` rejects — a `NonZeroUsize`
1871    /// has no other readable spelling. The fallback is unreachable for any
1872    /// count a caller writes, and `NonZeroUsize::MIN` is the honest answer if
1873    /// one does arrive: a zero-capacity store rather than a panic in a test.
1874    fn room_for(keys: usize) -> NonZeroUsize {
1875        NonZeroUsize::new(keys).unwrap_or(NonZeroUsize::MIN)
1876    }
1877
1878    /// A store over the real one, logging each operation.
1879    struct Recording {
1880        inner: crate::adapters::secondary::idempotency::InMemoryIdempotencyStore<JsonObject>,
1881        log: Arc<Journal>,
1882    }
1883
1884    impl crate::application::idempotency::IdempotencyStore for Recording {
1885        type Result = JsonObject;
1886
1887        fn claim(
1888            &self,
1889            key: &crate::domain::idempotency::IdempotencyKey,
1890        ) -> Result<
1891            crate::domain::idempotency::Claim<Self::Result>,
1892            crate::domain::idempotency::IdempotencyError,
1893        > {
1894            self.log
1895                .log
1896                .lock()
1897                .unwrap_or_else(std::sync::PoisonError::into_inner)
1898                .push("claim");
1899            self.inner.claim(key)
1900        }
1901
1902        fn complete(
1903            &self,
1904            token: &crate::domain::idempotency::ClaimToken,
1905            result: Self::Result,
1906        ) -> Result<(), crate::domain::idempotency::IdempotencyError> {
1907            self.log
1908                .log
1909                .lock()
1910                .unwrap_or_else(std::sync::PoisonError::into_inner)
1911                .push("complete");
1912            self.inner.complete(token, result)
1913        }
1914
1915        fn abandon(
1916            &self,
1917            token: &crate::domain::idempotency::ClaimToken,
1918        ) -> Result<(), crate::domain::idempotency::IdempotencyError> {
1919            self.log
1920                .log
1921                .lock()
1922                .unwrap_or_else(std::sync::PoisonError::into_inner)
1923                .push("abandon");
1924            self.inner.abandon(token)
1925        }
1926    }
1927
1928    /// A recorded call is `claim` then `complete`, and nothing else.
1929    ///
1930    /// The order is the claim: recording before executing would answer a
1931    /// concurrent caller with a result that does not exist yet, and never
1932    /// recording would answer a retry by running the tool again.
1933    #[tokio::test]
1934    async fn a_successful_call_claims_then_records() -> Result<(), TestError> {
1935        let journal = Arc::new(Journal::default());
1936        let store = Arc::new(Recording {
1937            inner: crate::adapters::secondary::idempotency::InMemoryIdempotencyStore::with_capacity(
1938                room_for(8),
1939            ),
1940            log: Arc::clone(&journal),
1941        });
1942        let count = Arc::new(AtomicUsize::new(0));
1943        let seen = Arc::clone(&count);
1944        let handler: ToolHandler = Arc::new(move |_ctx, _args| {
1945            let count = Arc::clone(&seen);
1946            Box::pin(async move {
1947                count.fetch_add(1, Ordering::SeqCst);
1948                JsonObject::new(r#"{"charged":true}"#).unwrap_or_default()
1949            })
1950        });
1951        let dispatcher = Dispatcher::new(Arc::new(
1952            Server::builder("test")
1953                .tool(charge_tool()?, handler)
1954                .idempotency(store)
1955                .build(),
1956        ));
1957        let version = ProtocolVersion::V2025_06_18;
1958        let peer = peer(version, ScopeSet::all(), caps(version));
1959        let params = JsonObject::new(r#"{"name":"charge"}"#)?;
1960
1961        let _ = dispatcher
1962            .dispatch(&ctx(&peer), "tools/call", &params)
1963            .await?;
1964        // The retry claims again — the key is still there — and then stops,
1965        // because the answer is already recorded.
1966        let _ = dispatcher
1967            .dispatch(&ctx(&peer), "tools/call", &params)
1968            .await?;
1969
1970        if journal.entries() != vec!["claim", "complete", "claim"] {
1971            return Err(fail(format!("the store saw {:?}", journal.entries())));
1972        }
1973        if count.load(Ordering::SeqCst) != 1 {
1974            return Err(fail(format!(
1975                "the tool ran {} times",
1976                count.load(Ordering::SeqCst)
1977            )));
1978        }
1979        Ok(())
1980    }
1981
1982    /// A call that fails is `claim` then `abandon`, and the key is reusable.
1983    ///
1984    /// The gate is the only place a `tools/call` can fail — a handler that
1985    /// wants to report a problem returns `isError` content, which is a
1986    /// *result*, not an error — so the session below is one that cannot see
1987    /// the tool. The observable is what happens on the second call: a released
1988    /// key is claimable and the gate is consulted again, and a recorded one
1989    /// would come straight back as the recorded refusal without ever reaching
1990    /// a handler.
1991    #[tokio::test]
1992    async fn a_refused_call_releases_the_key() -> Result<(), TestError> {
1993        let journal = Arc::new(Journal::default());
1994        let store = Arc::new(Recording {
1995            inner: crate::adapters::secondary::idempotency::InMemoryIdempotencyStore::with_capacity(
1996                room_for(8),
1997            ),
1998            log: Arc::clone(&journal),
1999        });
2000        let secret = Registered::from(Primitive::from(Tool::new(
2001            "secret",
2002            r#"{"type":"object"}"#,
2003        )?))
2004        .requiring_scope(Scope::Read);
2005        let handler: ToolHandler = Arc::new(|_ctx, _args| {
2006            Box::pin(async { JsonObject::new(r#"{"leaked":true}"#).unwrap_or_default() })
2007        });
2008        let dispatcher = Dispatcher::new(Arc::new(
2009            Server::builder("test")
2010                .restricted_tool(secret, handler)
2011                .idempotency(store)
2012                .build(),
2013        ));
2014        // A session with no scopes cannot see a `Read`-restricted tool.
2015        let version = ProtocolVersion::V2025_06_18;
2016        let peer = peer(version, ScopeSet::empty(), caps(version));
2017        let params = JsonObject::new(r#"{"name":"secret"}"#)?;
2018
2019        for _ in 0..2 {
2020            let outcome = dispatcher
2021                .dispatch(&ctx(&peer), "tools/call", &params)
2022                .await;
2023            if outcome != Err(DispatchError::UnknownTool) {
2024                return Err(fail(format!("a hidden tool answered {outcome:?}")));
2025            }
2026        }
2027        // A released key is claimable, so both attempts claim it. A recorded
2028        // one would show `claim` twice and then nothing — but with the
2029        // recorded refusal returned before the handler, and the log would
2030        // still read the same, which is why the refusal itself is asserted
2031        // above rather than inferred from the log.
2032        if journal.entries() != vec!["claim", "abandon", "claim", "abandon"] {
2033            return Err(fail(format!("the store saw {:?}", journal.entries())));
2034        }
2035        Ok(())
2036    }
2037
2038    /// A second request with an id the first still holds is refused, not run.
2039    ///
2040    /// The handler parks until released, so the claim is checked while the
2041    /// first call is genuinely mid-flight — which is the only state where
2042    /// `InFlight` exists, and the only state where running twice is possible.
2043    #[tokio::test]
2044    async fn a_duplicate_while_the_first_is_running_is_refused() -> Result<(), TestError> {
2045        let count = Arc::new(AtomicUsize::new(0));
2046        let (gate_tx, gate_rx) = tokio::sync::oneshot::channel::<()>();
2047        let gate_rx = Arc::new(tokio::sync::Mutex::new(Some(gate_rx)));
2048        let seen = Arc::clone(&count);
2049        let handler: ToolHandler = Arc::new(move |_ctx, _args| {
2050            let count = Arc::clone(&seen);
2051            let gate_rx = Arc::clone(&gate_rx);
2052            Box::pin(async move {
2053                // Park until the test releases it, so the first call is
2054                // provably still holding its key. The guard is dropped
2055                // before the `if let`, not at the end of it: held across the
2056                // inner `await` it would be a lock this task does not
2057                // release, which is the deadlock `significant_drop_in_scrutinee`
2058                // exists to catch rather than a style note.
2059                let waiting = { gate_rx.lock().await.take() };
2060                if let Some(receiver) = waiting {
2061                    let _ = receiver.await;
2062                }
2063                count.fetch_add(1, Ordering::SeqCst);
2064                JsonObject::new(r#"{"charged":true}"#).unwrap_or_default()
2065            })
2066        });
2067        let dispatcher = Dispatcher::new(Arc::new(
2068            Server::builder("test")
2069                .tool(charge_tool()?, handler)
2070                .build(),
2071        ));
2072        let peer = peer(
2073            ProtocolVersion::V2025_06_18,
2074            ScopeSet::all(),
2075            caps(ProtocolVersion::V2025_06_18),
2076        );
2077        let params = JsonObject::new(r#"{"name":"charge"}"#)?;
2078
2079        // The spawned call borrows nothing: `Dispatcher` is `Clone` and
2080        // `ctx` builds a fresh context over the same session, which is what
2081        // makes this a genuine race rather than two calls one after another.
2082        let running = tokio::spawn({
2083            let dispatcher = Dispatcher::new(Arc::clone(dispatcher.server()));
2084            let peer = peer_on(
2085                ProtocolVersion::V2025_06_18,
2086                ScopeSet::all(),
2087                caps(ProtocolVersion::V2025_06_18),
2088                1,
2089            );
2090            let params = JsonObject::new(r#"{"name":"charge"}"#)?;
2091            async move {
2092                dispatcher
2093                    .dispatch(&ctx(&peer), "tools/call", &params)
2094                    .await
2095            }
2096        });
2097        // Let the spawned call reach the handler, which is where it parks.
2098        // Without this the duplicate could be issued first and be the one
2099        // that claims the key, and the test would pass for the wrong reason.
2100        tokio::task::yield_now().await;
2101
2102        let duplicate = dispatcher
2103            .dispatch(&ctx(&peer), "tools/call", &params)
2104            .await;
2105        let _ = gate_tx.send(());
2106        let _ = running.await;
2107
2108        match duplicate {
2109            Err(DispatchError::DuplicateInFlight(_)) => {}
2110            other => return Err(fail(format!("the duplicate answered {other:?}"))),
2111        }
2112        if count.load(Ordering::SeqCst) != 1 {
2113            return Err(fail(format!(
2114                "the tool ran {} time(s) while a duplicate was refused",
2115                count.load(Ordering::SeqCst)
2116            )));
2117        }
2118        Ok(())
2119    }
2120}