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", ¶ms)
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", ¶ms)
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", ¶ms)
1795 .await?;
1796 // The retry, same id.
1797 let second = dispatcher
1798 .dispatch(&ctx(&peer), "tools/call", ¶ms)
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", ¶ms)
1831 .await?;
1832 let _ = dispatcher
1833 .dispatch(&ctx(&second_peer), "tools/call", ¶ms)
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", ¶ms)
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", ¶ms)
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", ¶ms)
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", ¶ms)
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", ¶ms)
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}