Skip to main content

protocol_droid/application/server/
context.rs

1//! What a handler is given, and the gate in front of it.
2//!
3//! # The rule this module exists to enforce
4//!
5//! A feature-specific call consults the session's negotiated
6//! [`FeatureProfile`] *before* anything is written. Not after, and not "write
7//! and let the peer cope" — before. A server that emits
8//! `elicitation/create` to a `2025-03-26` peer has told it something the
9//! protocol does not allow, and no error afterwards repairs that.
10//!
11//! The testable form of that rule is [`GatedError::wrote_nothing`]: every
12//! refusal in this module either wrote nothing at all, or is explicitly marked
13//! as the one case that got far enough to write a request and then failed on
14//! the answer. The two halves are not the same and the type says so.
15//!
16//! # Why there are two types and not one
17//!
18//! [`PeerSession`] is everything about the _connection_: the revision it
19//! settled on, the profile that fell out of that, and the two ports a handler
20//! needs to talk back. [`RequestContext`] is everything about the _request_
21//! being served, and holds a reference to a session.
22//!
23//! The split is not tidiness. A profile and a transport are fixed when the
24//! connection negotiates and shared by every request on it; a request id, a
25//! cancellation token and a progress token differ on each. Collapsing them
26//! produces a constructor with eight parameters, five of which every caller has
27//! to invent for every request, and nothing stops a caller from taking them
28//! from the wrong request.
29
30use std::any::{Any, TypeId};
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use crate::application::mcp::input_required::InputRound;
35use crate::application::server::error::GatedError;
36use crate::application::server::log_gate::LogGate;
37use crate::application::session::DynFrameSink;
38use crate::domain::feature::{Feature, FeatureProfile};
39use crate::domain::ir::{JsonObject, LogLevel};
40use crate::domain::registry::AuthContext;
41use crate::domain::session::{CancellationToken, ProgressToken, SessionId};
42use crate::domain::{ProtocolVersion, RequestId, TransportKind};
43
44// The callback-exchange port, re-exported under its old names. `PeerSession`
45// holds a `DynExchange` and the doc example on that type imports
46// `CallbackExchange` and `ExchangeError` from this module, so
47// `context::CallbackExchange` has to keep resolving.
48pub use super::exchange::{CallbackExchange, DynExchange, ExchangeError};
49
50/// The type-keyed state a `Server` carries into every request.
51///
52/// Each `Server::with_state::<T>(value)` registers one entry; every
53/// `RequestContext` clones an `Arc<StateMap>` rather than the values
54/// themselves, so a thousand in-flight requests pay a thousand `Arc`
55/// bumps rather than a thousand deep copies.
56///
57/// `Any` rather than a typed `HashMap<TypeId, T>` because a server
58/// accumulates entries for *different* `T`s — a database pool, a
59/// configuration, a metrics handle — and a typed map is only one of
60/// those at a time. The `Send + Sync` bounds on the values match what
61/// every other port in this crate asks for.
62pub type StateMap = HashMap<TypeId, Arc<dyn Any + Send + Sync>>;
63
64/// The negotiated connection a handler is serving.
65///
66/// Cheap to clone: a `Copy` revision, a `Copy` profile, a `Copy` session id and
67/// two `Arc`s. A handler may hand one to a spawned task without the ceremony a
68/// deep type would need.
69///
70/// # Examples
71///
72/// ```
73/// use std::sync::Arc;
74///
75/// use protocol_droid::application::server::context::{
76///     CallbackExchange, ExchangeError, PeerSession,
77/// };
78/// use protocol_droid::domain::feature::{Feature, FeatureProfile};
79/// use protocol_droid::domain::ir::JsonObject;
80/// use protocol_droid::domain::session::SessionId;
81/// use protocol_droid::domain::{ProtocolVersion, TransportKind};
82///
83/// struct NoExchange;
84/// # impl CallbackExchange for NoExchange {
85/// #     async fn exchange(&self, _: &str, _: &JsonObject) -> Result<JsonObject, ExchangeError> {
86/// #         Err(ExchangeError::new("no peer"))
87/// #     }
88/// # }
89/// # struct NullSink;
90/// # impl protocol_droid::application::session::FrameSink for NullSink {
91/// #     async fn send(&self, _: &str) -> Result<(), protocol_droid::application::session::SinkError> { Ok(()) }
92/// # }
93///
94/// let peer = PeerSession::new(
95///     ProtocolVersion::V2025_03_26,
96///     FeatureProfile::empty().with(Feature::Sampling),
97///     SessionId::new(1),
98///     TransportKind::Stdio,
99///     Arc::new(NoExchange),
100///     Arc::new(NullSink),
101/// );
102///
103/// // Elicitation was never negotiated; sampling was. A handler that asked the
104/// // question up front gets an answer rather than a frame the peer may reject.
105/// assert!(!peer.allows(Feature::Elicitation));
106/// assert!(peer.allows(Feature::Sampling));
107/// ```
108#[derive(Clone)]
109pub struct PeerSession {
110    negotiated: ProtocolVersion,
111    profile: FeatureProfile,
112    session: SessionId,
113    transport: TransportKind,
114    auth: AuthContext,
115    exchange: Arc<dyn DynExchange>,
116    sink: Arc<dyn DynFrameSink>,
117    log: LogGate,
118}
119
120impl core::fmt::Debug for PeerSession {
121    /// Prints the negotiation, and deliberately not the ports.
122    ///
123    /// A sink holds a channel and an exchange holds a socket; neither is
124    /// readable and both are large, so the derived form would print
125    /// unreadable noise. The two fields a post-mortem wants are the revision
126    /// and how much the session can do.
127    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
128        f.debug_struct("PeerSession")
129            .field("negotiated", &self.negotiated)
130            .field("session", &self.session)
131            .field("principal", &self.auth.principal())
132            .field("feature_count", &self.profile.len())
133            .finish_non_exhaustive()
134    }
135}
136
137impl PeerSession {
138    /// Records what a connection negotiated, and where its frames go.
139    ///
140    /// The caller is [`AuthContext::anonymous`], which holds no scopes. That
141    /// is a default-deny default and not a convenience: a primitive requiring
142    /// a scope is invisible to an anonymous session, and one requiring none is
143    /// visible to everybody. [`with_auth`](Self::with_auth) is how a real
144    /// principal arrives, so a transport that never calls it serves exactly
145    /// the primitives nobody had to authorise.
146    #[must_use]
147    pub fn new(
148        negotiated: ProtocolVersion,
149        profile: FeatureProfile,
150        session: SessionId,
151        transport: TransportKind,
152        exchange: Arc<dyn DynExchange>,
153        sink: Arc<dyn DynFrameSink>,
154    ) -> Self {
155        Self {
156            negotiated,
157            profile,
158            session,
159            transport,
160            auth: AuthContext::anonymous(),
161            exchange,
162            sink,
163            log: LogGate::open(),
164        }
165    }
166
167    /// This peer, filtering its log records through `gate`.
168    ///
169    /// A stateful session keeps the open gate it was built with and moves it
170    /// with `logging/setLevel`. A `2026-07-28` request replaces it with one
171    /// built from its own `_meta` — see [`LogGate::for_request`].
172    #[must_use]
173    pub fn with_log_gate(mut self, gate: LogGate) -> Self {
174        self.log = gate;
175        self
176    }
177
178    /// The threshold this peer's log records are filtered against.
179    #[must_use]
180    pub const fn log_gate(&self) -> &LogGate {
181        &self.log
182    }
183
184    /// The transport this session runs over.
185    ///
186    /// Part of the session rather than of the caller because a profile is not
187    /// resolvable without one: [`resolve`](crate::domain::feature::resolve) consults `transport_allowed` for
188    /// every transport-gated feature row, so a handshake that needed the
189    /// negotiated profile had nowhere to get the transport from until this
190    /// carried it.
191    #[must_use]
192    pub const fn transport(&self) -> TransportKind {
193        self.transport
194    }
195
196    /// The caller, for the visibility gate.
197    #[must_use]
198    pub const fn auth(&self) -> &AuthContext {
199        &self.auth
200    }
201
202    /// Narrows what this session may do.
203    ///
204    /// Separate from the constructor because a profile can be narrowed after
205    /// the fact in a way it cannot be widened. The `2026-07-28` stateless
206    /// lifecycle is the reason: every request carries its own version and
207    /// capabilities in `_meta` and is served against its own profile, so the
208    /// connection settles one profile and each request may hold less.
209    ///
210    /// Narrowing only, deliberately. Widening would let a caller grant a
211    /// session a feature the negotiation never produced, and that is the
212    /// "rules-tightening in code" hazard in its most dangerous form: the
213    /// refusal it defeats is a refusal the peer was never entitled to.
214    #[must_use]
215    pub const fn with_profile(mut self, profile: FeatureProfile) -> Self {
216        self.profile = profile;
217        self
218    }
219
220    /// Records who the caller is.
221    ///
222    /// Per **request**, and the distinction is the one that goes wrong
223    /// silently. A bearer token can be refreshed, replaced, or withdrawn
224    /// between two requests on one session, so the principal belongs to the
225    /// [`RequestContext`] being served rather than to the session the peer was
226    /// negotiated into. A transport therefore overlays the context it resolved
227    /// onto the session peer per request, and what it persists carries the
228    /// anonymous default that [`without_auth`](Self::without_auth) restores.
229    #[must_use]
230    pub fn with_auth(mut self, auth: AuthContext) -> Self {
231        self.auth = auth;
232        self
233    }
234
235    /// This peer's session state with the principal dropped.
236    ///
237    /// What a transport stores when it persists a handshake result. The
238    /// returned peer keeps the negotiated revision, the profile and the session
239    /// id — everything that *is* the session — and answers
240    /// [`auth`](Self::auth) with [`AuthContext::anonymous`].
241    ///
242    /// A handshake narrows the context it was handed, so the peer it returns
243    /// inherits that context's principal. A transport that overlays the
244    /// request's own context per request overwrites it on every request and
245    /// never observes the difference, which is precisely why it is worth
246    /// stating rather than leaving to chance: a principal recorded against
247    /// session state has the wrong lifetime for a token that can be refreshed,
248    /// replaced, or withdrawn mid-session, and the next reader of the session
249    /// to skip that overlay would be answered as whoever opened it.
250    #[must_use]
251    pub fn without_auth(mut self) -> Self {
252        self.auth = AuthContext::anonymous();
253        self
254    }
255
256    /// This peer, opening server-initiated requests through `exchange`.
257    ///
258    /// The partner to [`Self::with_sink`]: the sink is where a notification
259    /// *goes*, the exchange is how a request *leaves and waits*. A transport
260    /// that publishes both a tool's log records and its `sampling/createMessage`
261    /// through one session has to bind both to that session, or the log records
262    /// reach one client and the sampling request waits for another.
263    ///
264    /// Replaces rather than layers: a peer has one exchange, and keeping a
265    /// second that silently refuses would make a mis-bound peer refuse rather
266    /// than fail loudly.
267    #[must_use]
268    pub fn with_exchange(mut self, exchange: Arc<dyn DynExchange>) -> Self {
269        self.exchange = exchange;
270        self
271    }
272
273    /// This peer, identified as `id` rather than as the seed it was built from.
274    ///
275    /// A transport that opens more than one session needs this, and the reason
276    /// is invisible until two sessions are live. Everything a session *does* is
277    /// keyed on its id: the idempotency store that makes a retried `tools/call`
278    /// replay its recorded answer, the in-flight map that refuses a duplicate.
279    /// Both read `peer.session()`. A peer seeded with one id and never rebind
280    /// therefore gives every session the same key space, so the first session's
281    /// recorded `tools/call` answer is handed back to the second session's
282    /// call as its "already completed" result — another tool's output, served
283    /// as if it were this one's.
284    ///
285    /// The store allocates the id; this is where it reaches the peer.
286    #[must_use]
287    pub const fn with_session(mut self, id: SessionId) -> Self {
288        self.session = id;
289        self
290    }
291
292    /// This peer, publishing its server-initiated messages through `sink`.
293    ///
294    /// The peer is constructed with a sink long before a transport knows where
295    /// that session's messages will be read from — a handshake happens inside
296    /// a request whose response stream does not exist yet, and a transport
297    /// that seeds a session with one placeholder and persists it finds later
298    /// that the placeholder is still what the session writes to. Binding it
299    /// here, once the session has a tail worth publishing to, is the only
300    /// point where the two can be made the same object.
301    ///
302    /// Replaces rather than layers: a peer has exactly one sink, and keeping a
303    /// second that quietly discards would restore the failure this exists to
304    /// prevent.
305    #[must_use]
306    pub fn with_sink(mut self, sink: Arc<dyn DynFrameSink>) -> Self {
307        self.sink = sink;
308        self
309    }
310
311    /// The revision this connection settled on.
312    #[must_use]
313    pub const fn negotiated(&self) -> ProtocolVersion {
314        self.negotiated
315    }
316
317    /// The profile that revision and the peer's capabilities produced.
318    #[must_use]
319    pub const fn profile(&self) -> FeatureProfile {
320        self.profile
321    }
322
323    /// Which session this is.
324    #[must_use]
325    pub const fn session(&self) -> SessionId {
326        self.session
327    }
328
329    /// Whether `feature` may be used on this connection.
330    #[must_use]
331    pub fn allows(&self, feature: Feature) -> bool {
332        self.profile.allows(feature)
333    }
334
335    /// The port for a server-initiated round trip.
336    #[must_use]
337    pub const fn exchange(&self) -> &Arc<dyn DynExchange> {
338        &self.exchange
339    }
340
341    /// The port for a one-way notification.
342    #[must_use]
343    pub const fn sink(&self) -> &Arc<dyn DynFrameSink> {
344        &self.sink
345    }
346
347    /// The gate, in one place, called by every gated method.
348    fn require(&self, feature: Feature) -> Result<(), GatedError> {
349        if self.profile.allows(feature) {
350            return Ok(());
351        }
352        Err(GatedError::FeatureUnavailable {
353            feature,
354            negotiated: self.negotiated,
355        })
356    }
357}
358
359/// Everything a handler is allowed to know about the call it is serving.
360#[derive(Clone)]
361pub struct RequestContext {
362    peer: PeerSession,
363    id: RequestId,
364    cancel: CancellationToken,
365    progress: Option<ProgressToken>,
366    /// The `Server`'s registered state, sharable across every request.
367    ///
368    /// `Arc` so cloning the context for a long handler is two pointer
369    /// bumps; an empty `StateMap` is one allocation's worth of memory
370    /// for a server that never called `with_state`, so a thousand
371    /// requests cost a thousand pointer bumps rather than a thousand
372    /// empty hashmaps.
373    state: Arc<StateMap>,
374    /// What a SEP-2322 retry carried back, already verified. Empty on a first
375    /// round and on every revision before `2026-07-28`.
376    input: InputRound,
377}
378
379impl core::fmt::Debug for RequestContext {
380    /// Prints what a post-mortem needs and nothing else.
381    ///
382    /// Handwritten because the derived form would print the request id in
383    /// full, and a request id in a log line is exactly what the redaction rule
384    /// exists to prevent. The id goes through [`RequestId::redacted`].
385    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
386        f.debug_struct("RequestContext")
387            .field("request", &self.id.redacted())
388            .field("negotiated", &self.peer.negotiated)
389            .field("has_progress_token", &self.progress.is_some())
390            .field("cancelled", &self.cancel.is_cancelled())
391            .field("state_entries", &self.state.len())
392            .finish_non_exhaustive()
393    }
394}
395
396impl RequestContext {
397    /// Binds one in-flight request to the connection serving it.
398    ///
399    /// Clones the session, which is two `Arc` bumps and two `Copy` fields. That
400    /// is why a server with a thousand in-flight requests pays a thousand
401    /// pointer bumps rather than a thousand deep copies of the profile.
402    ///
403    /// The state map is empty. A test that needs state should use
404    /// [`for_request_with_state`](Self::for_request_with_state); a
405    /// production handler gets the map from the `Server` it was
406    /// built with.
407    #[must_use]
408    pub fn for_request(
409        peer: &PeerSession,
410        id: RequestId,
411        cancel: CancellationToken,
412        progress: Option<ProgressToken>,
413    ) -> Self {
414        Self {
415            peer: peer.clone(),
416            id,
417            cancel,
418            progress,
419            state: Arc::new(HashMap::new()),
420            input: InputRound::default(),
421        }
422    }
423
424    /// Binds one in-flight request to the connection serving it, with the
425    /// server's registered state.
426    ///
427    /// The state map is the one the
428    /// [`Server`](crate::application::server::Server) accumulated
429    /// through `with_state` calls. The context holds an `Arc`
430    /// rather than the values, so a thousand concurrent requests
431    /// share one allocation rather than each deep-copying the
432    /// database pool.
433    #[must_use]
434    pub fn for_request_with_state(
435        peer: &PeerSession,
436        id: RequestId,
437        cancel: CancellationToken,
438        progress: Option<ProgressToken>,
439        state: Arc<StateMap>,
440    ) -> Self {
441        Self {
442            peer: peer.clone(),
443            id,
444            cancel,
445            progress,
446            state,
447            input: InputRound::default(),
448        }
449    }
450
451    /// This context, carrying a retry's verified answers and state.
452    #[must_use]
453    pub(crate) fn with_input_round(mut self, input: InputRound) -> Self {
454        self.input = input;
455        self
456    }
457
458    /// The client's answer to the input request filed under `key`.
459    ///
460    /// `None` on a first round, and on a retry that did not answer that key —
461    /// which a handler should treat as "ask again" rather than as an error,
462    /// because SEP-2322 has the server re-request what is missing.
463    #[must_use]
464    pub fn input_response(&self, key: &str) -> Option<JsonObject> {
465        self.input.response(key)
466    }
467
468    /// Whether this request is a retry carrying `inputResponses` at all.
469    #[must_use]
470    pub const fn has_input_responses(&self) -> bool {
471        self.input.has_responses()
472    }
473
474    /// The `requestState` this server sealed on the previous round.
475    ///
476    /// Already verified: a state that did not open was refused before the
477    /// handler ran, so anything returned here is the server's own.
478    #[must_use]
479    pub fn request_state(&self) -> Option<&str> {
480        self.input.state()
481    }
482
483    /// Seals `state` for an [`InputRequired`] result's `requestState`.
484    ///
485    /// [`InputRequired`]: crate::application::mcp::input_required::InputRequired
486    ///
487    /// # Errors
488    ///
489    /// [`GatedError::NoStateKey`] when the server was built without a key and
490    /// no transport supplied one, so there is nothing to seal under.
491    pub fn seal_state(&self, state: &str) -> Result<String, GatedError> {
492        self.input.seal(state).ok_or(GatedError::NoStateKey)
493    }
494
495    /// Looks up a state value by its concrete type.
496    ///
497    /// The state map is keyed by [`TypeId`], and the value is stored as
498    /// `Arc<dyn Any>`, so the lookup is two `HashMap` operations and one
499    /// downcast. The `Send + Sync` bounds are the same the
500    /// [`Server::with_state`](crate::application::server::Server::with_state)
501    /// builder requires, so a `T` registered here is reachable here.
502    ///
503    /// Returns `None` for a type that was never registered. That is
504    /// the right answer for a state the handler treats as optional;
505    /// a handler that *requires* a piece of state should `.expect`
506    /// the result with a message naming the missing type, or it
507    /// should be registered at server construction.
508    ///
509    /// # Examples
510    ///
511    /// ```ignore
512    /// #[tool]
513    /// async fn search(ctx: RequestContext, query: String) -> Result<JsonObject, ToolError> {
514    ///     let db = ctx.state::<MyPool>().expect("MyPool was registered");
515    ///     db.search(query).await
516    /// }
517    /// ```
518    #[must_use]
519    pub fn state<T: Send + Sync + 'static>(&self) -> Option<&T> {
520        self.state
521            .get(&TypeId::of::<T>())
522            .and_then(|value| value.downcast_ref::<T>())
523    }
524
525    /// The connection serving this request.
526    #[must_use]
527    pub const fn peer(&self) -> &PeerSession {
528        &self.peer
529    }
530
531    /// The request id, for correlating anything the handler emits itself.
532    #[must_use]
533    pub const fn request_id(&self) -> &RequestId {
534        &self.id
535    }
536
537    /// The caller's cancellation token, so a long handler can observe it.
538    #[must_use]
539    pub const fn cancellation(&self) -> &CancellationToken {
540        &self.cancel
541    }
542
543    /// The progress token the client opened, if it opened one.
544    #[must_use]
545    pub const fn progress_token(&self) -> Option<&ProgressToken> {
546        self.progress.as_ref()
547    }
548
549    /// Asks the peer for extra input from the user.
550    ///
551    /// Gated on [`Feature::Elicitation`]. The gate runs first, so a session
552    /// that never negotiated it writes nothing at all — see
553    /// [`GatedError::wrote_nothing`].
554    ///
555    /// # Errors
556    ///
557    /// [`GatedError::FeatureUnavailable`] when elicitation was not negotiated,
558    /// having written nothing. [`GatedError::MalformedAnswer`] when the
559    /// exchange did not complete, which is the one case here where the request
560    /// did reach the wire first.
561    pub async fn elicit(&self, params: &JsonObject) -> Result<JsonObject, GatedError> {
562        self.peer.require(Feature::Elicitation)?;
563        self.round_trip("elicitation/create", params).await
564    }
565
566    /// Asks the peer to run a model on the server's behalf.
567    ///
568    /// Gated on [`Feature::Sampling`], on the same terms as `InputRequired::elicit`.
569    ///
570    /// # Errors
571    ///
572    /// As [`InputRequired::elicit`](crate::application::mcp::input_required::InputRequired::elicit).
573    pub async fn sample(&self, params: &JsonObject) -> Result<JsonObject, GatedError> {
574        self.peer.require(Feature::Sampling)?;
575        self.round_trip("sampling/createMessage", params).await
576    }
577
578    /// Opens a long-running task for this request.
579    ///
580    /// Gated on [`Feature::Tasks`], which enters the core protocol in
581    /// `2025-11-25` and moves to an extension in `2026-07-28` — so the same
582    /// call is available, unavailable, and available-elsewhere across three
583    /// revisions, and the gate is what makes that legible.
584    ///
585    /// # Errors
586    ///
587    /// As [`InputRequired::elicit`](crate::application::mcp::input_required::InputRequired::elicit).
588    pub async fn create_task(&self, params: &JsonObject) -> Result<JsonObject, GatedError> {
589        self.peer.require(Feature::Tasks)?;
590        self.round_trip("tasks/create", params).await
591    }
592
593    /// Reports progress on a request the client opened a channel for.
594    ///
595    /// Not feature-gated, and deliberately: progress is core to every revision
596    /// from `2024-11-05` onward, so a `Feature` for it would be a fiction.
597    /// What can be absent is the _token_ — the client chose whether to open
598    /// the channel, and a report without one has nowhere to go.
599    ///
600    /// # Errors
601    ///
602    /// [`GatedError::NoProgressToken`] when the client did not open a progress
603    /// channel for this request, having written nothing.
604    pub async fn report_progress(&self, params: &JsonObject) -> Result<(), GatedError> {
605        let Some(token) = self.progress.as_ref() else {
606            return Err(GatedError::NoProgressToken);
607        };
608        let frame = Self::progress_frame(token, params);
609        self.peer
610            .sink
611            .send(&frame)
612            .await
613            .map_err(|_| GatedError::MalformedAnswer {
614                reason: "the progress notification did not reach the peer".to_string(),
615            })
616    }
617
618    /// Emits a `notifications/message` log record.
619    ///
620    /// The fourth of the server-side channels a handler can open, alongside
621    /// progress, sampling and elicitation, and the only one that needs no
622    /// token from the caller: a log record is addressed to the peer as a whole
623    /// rather than to a request, so there is nothing to correlate it against.
624    /// That is what makes it the odd one out here — every other gated
625    /// operation has a precondition the peer establishes first, and this one
626    /// has none.
627    ///
628    /// Gated on [`Feature::Logging`], and like the other gates it runs before
629    /// anything is written, so a session that never negotiated logging emits
630    /// nothing at all rather than a record the peer will ignore.
631    ///
632    /// # Errors
633    ///
634    /// [`GatedError::FeatureUnavailable`] when logging was not negotiated,
635    /// having written nothing. [`GatedError::MalformedAnswer`] when the
636    /// notification did not reach the peer.
637    pub async fn log(&self, level: LogLevel, message: &str) -> Result<(), GatedError> {
638        self.peer.require(Feature::Logging)?;
639        // Below the threshold is not a failure: the peer asked not to hear it,
640        // and a record nobody wants is one nobody should be made to read.
641        if !self.peer.log.admits(level) {
642            return Ok(());
643        }
644        let escaped = serde_json::to_string(message).unwrap_or_else(|reason| {
645            format!("\"the log message could not be serialised: {reason}\"")
646        });
647        let frame = format!(
648            r#"{{"jsonrpc":"2.0","method":"notifications/message","params":{{"level":{},"data":{}}}}}"#,
649            serde_json::to_string(level.as_str()).unwrap_or_else(|_| "\"info\"".to_owned()),
650            escaped
651        );
652        self.peer
653            .sink
654            .send(&frame)
655            .await
656            .map_err(|_| GatedError::MalformedAnswer {
657                reason: "the log notification did not reach the peer".to_string(),
658            })
659    }
660
661    /// Sends the request and reads the answer, mapping a bad answer to the one
662    /// error that is not a gate.
663    async fn round_trip(
664        &self,
665        method: &str,
666        params: &JsonObject,
667    ) -> Result<JsonObject, GatedError> {
668        self.peer
669            .exchange
670            .exchange_boxed(method, params)
671            .await
672            .map_err(|failure| GatedError::MalformedAnswer {
673                reason: failure.to_string(),
674            })
675    }
676
677    /// The JSON-RPC notification a progress report travels in.
678    ///
679    /// Built with `serde_json` rather than by concatenating into a `String`.
680    /// Hand-building a document is how a token containing a quote produces
681    /// something that is not JSON — the same defect T09 hit by rendering an id
682    /// with `Debug`.
683    ///
684    /// An associated function rather than a method because it reads nothing
685    /// from the context: the token and the caller's params are the whole
686    /// message, and a `&self` here would suggest a request id or a profile
687    /// leaks into the frame when neither does.
688    fn progress_frame(token: &ProgressToken, params: &JsonObject) -> String {
689        let mut members = serde_json::Map::new();
690        // The token goes back in the JSON type it arrived in. A number written
691        // back as a string is a different token, and a client correlating by
692        // value never matches the update to the request that asked for it.
693        let echoed = match token {
694            ProgressToken::Number(value) => serde_json::Value::from(*value),
695            ProgressToken::Text(value) => serde_json::Value::String(value.to_string()),
696        };
697        members.insert("progressToken".to_string(), echoed);
698        // The caller's params are already validated JSON, so they are spliced
699        // in as a value rather than re-escaped. A caller who supplied a
700        // `progressToken` of their own loses to the real one: the token is not
701        // a hint, it is the routing key, and two of them leave the peer unable
702        // to tell which stream a report belongs to.
703        if let Ok(serde_json::Value::Object(map)) = serde_json::from_str(params.as_str()) {
704            for (key, value) in map {
705                if key != "progressToken" {
706                    members.insert(key, value);
707                }
708            }
709        }
710        let frame = serde_json::json!({
711            "jsonrpc": "2.0",
712            "method": "notifications/progress",
713            "params": serde_json::Value::Object(members),
714        });
715        // A `serde_json::Value` that cannot be written is a bug rather than a
716        // runtime condition, and returning a frame that names the failure beats
717        // panicking inside a handler the caller cannot catch.
718        serde_json::to_string(&frame).unwrap_or_else(|reason| {
719            format!(r#"{{"error":"the progress frame could not be serialised: {reason}"}}"#)
720        })
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use std::any::{Any, TypeId};
727    use std::collections::HashMap;
728    use std::sync::{Arc, Mutex};
729
730    use super::{
731        CallbackExchange, ExchangeError, GatedError, PeerSession, RequestContext, StateMap,
732    };
733    use crate::application::session::{FrameSink, SinkError};
734    use crate::domain::feature::{Feature, FeatureProfile};
735    use crate::domain::ir::{JsonObject, JsonObjectError};
736    use crate::domain::registry::{AuthContext, Scope, ScopeSet};
737    use crate::domain::session::{CancellationToken, ProgressToken, SessionId};
738    use crate::domain::{ProtocolVersion, RequestId, TransportKind};
739
740    /// What a test here can fail with.
741    ///
742    /// A `String` is not enough, because a test needs to fail on a
743    /// `JsonObjectError` from a fixture and on its own prose, and the only way
744    /// to have both is to name them. Without this, the first `return
745    /// Err(format!(..))` fixes the function's error type at `String` and every
746    /// later `?` stops compiling.
747    #[derive(Debug)]
748    enum Failure {
749        /// The test's own complaint.
750        Message(String),
751        /// A fixture was not a JSON object.
752        Fixture(JsonObjectError),
753        /// A frame the test produced was not JSON.
754        Serde(serde_json::Error),
755    }
756
757    impl core::fmt::Display for Failure {
758        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
759            match self {
760                Self::Message(text) => f.write_str(text),
761                Self::Fixture(error) => write!(f, "bad fixture: {error}"),
762                Self::Serde(error) => write!(f, "not JSON: {error}"),
763            }
764        }
765    }
766
767    impl std::error::Error for Failure {}
768
769    impl From<String> for Failure {
770        fn from(text: String) -> Self {
771            Self::Message(text)
772        }
773    }
774
775    impl From<&str> for Failure {
776        fn from(text: &str) -> Self {
777            Self::Message(text.to_string())
778        }
779    }
780
781    impl From<JsonObjectError> for Failure {
782        fn from(error: JsonObjectError) -> Self {
783            Self::Fixture(error)
784        }
785    }
786
787    impl From<serde_json::Error> for Failure {
788        fn from(error: serde_json::Error) -> Self {
789            Self::Serde(error)
790        }
791    }
792
793    impl From<GatedError> for Failure {
794        fn from(error: GatedError) -> Self {
795            Self::Message(error.to_string())
796        }
797    }
798
799    type TestError = Failure;
800
801    /// Reads a nested member out of a parsed frame without panicking.
802    ///
803    /// `serde_json::Value` indexes, and its `Index` impl panics on a missing
804    /// key — so `parsed["params"]["progressToken"]` turns a frame that came
805    /// out wrong into a test that aborts rather than one that reports what was
806    /// wrong with it. Which is the opposite of what a test is for.
807    fn at<'a>(
808        value: &'a serde_json::Value,
809        path: &[&str],
810    ) -> Result<&'a serde_json::Value, Failure> {
811        let mut cursor = value;
812        for (depth, key) in path.iter().enumerate() {
813            let object = cursor.as_object().ok_or_else(|| {
814                Failure::from(format!(
815                    "{path:?} is not an object at depth {depth}, found a {}",
816                    match cursor {
817                        serde_json::Value::Null => "null",
818                        serde_json::Value::Bool(_) => "bool",
819                        serde_json::Value::Number(_) => "number",
820                        serde_json::Value::String(_) => "string",
821                        serde_json::Value::Array(_) => "array",
822                        serde_json::Value::Object(_) => "object",
823                    }
824                ))
825            })?;
826            cursor = object
827                .get(*key)
828                .ok_or_else(|| Failure::from(format!("{path:?} has no member {key:?}")))?;
829        }
830        Ok(cursor)
831    }
832
833    /// Counts what reached the wire.
834    ///
835    /// The whole point of the gate is a claim about what did _not_ happen, and
836    /// a mock that only records successes cannot show it. A `Mutex<Vec<_>>`
837    /// rather than an `AtomicUsize` because the assertion is on the frames
838    /// themselves in the tests that check them.
839    #[derive(Default)]
840    struct RecordingSink {
841        frames: Mutex<Vec<String>>,
842    }
843
844    impl RecordingSink {
845        fn written(&self) -> Vec<String> {
846            // A poisoned lock means another test thread panicked while holding
847            // it. Taking the inner value anyway is right here: the vector is
848            // append-only and the assertion is on its contents, so a poison
849            // from an unrelated failure should not mask this one.
850            let frames = self
851                .frames
852                .lock()
853                .unwrap_or_else(std::sync::PoisonError::into_inner);
854            frames.clone()
855        }
856    }
857
858    impl FrameSink for RecordingSink {
859        /// Not `async fn`, despite the trait being an RPITIT.
860        ///
861        /// Recording a frame is a mutex push and nothing else; wrapping it in
862        /// an `async` block would build a state machine that is ready
863        /// immediately, which is a cost with no reader. Returning
864        /// `std::future::ready` says the same thing in one allocation fewer,
865        /// and the trait's `impl Future` return is satisfied either way.
866        fn send(&self, frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send {
867            // Scoped so the guard drops at the end of the block rather than at
868            // the end of the function: a MutexGuard held across an await is a
869            // bug in an async runtime, and holding it one line longer than
870            // needed is the habit that produces one.
871            {
872                let mut frames = self
873                    .frames
874                    .lock()
875                    .unwrap_or_else(std::sync::PoisonError::into_inner);
876                frames.push(frame.to_string());
877            }
878            std::future::ready(Ok(()))
879        }
880    }
881
882    /// Counts exchanges, so a test can prove the gate ran before the round
883    /// trip rather than after it.
884    #[derive(Default)]
885    struct CountingExchange {
886        calls: Mutex<Vec<String>>,
887        answer: Option<JsonObject>,
888    }
889
890    impl CountingExchange {
891        fn answering(answer: &str) -> Result<Self, TestError> {
892            Ok(Self {
893                calls: Mutex::new(Vec::new()),
894                answer: Some(JsonObject::new(answer)?),
895            })
896        }
897    }
898
899    impl CallbackExchange for CountingExchange {
900        fn exchange(
901            &self,
902            method: &str,
903            _params: &JsonObject,
904        ) -> impl Future<Output = Result<JsonObject, ExchangeError>> + Send {
905            {
906                let mut calls = self
907                    .calls
908                    .lock()
909                    .unwrap_or_else(std::sync::PoisonError::into_inner);
910                calls.push(method.to_string());
911            }
912            let outcome = self
913                .answer
914                .clone()
915                .ok_or_else(|| ExchangeError::new("no answer configured"));
916            std::future::ready(outcome)
917        }
918    }
919
920    /// A peer at `version` carrying `features`.
921    fn peer(
922        version: ProtocolVersion,
923        features: FeatureProfile,
924        exchange: &Arc<CountingExchange>,
925        sink: &Arc<RecordingSink>,
926    ) -> PeerSession {
927        PeerSession::new(
928            version,
929            features,
930            SessionId::new(7),
931            TransportKind::Stdio,
932            exchange.clone(),
933            sink.clone(),
934        )
935    }
936
937    /// Dropping the principal keeps everything the session *is*.
938    ///
939    /// Three claims, and the second is the one that would be easy to break by
940    /// writing `without_auth` as a fresh `PeerSession`. The scopes go — `holds`
941    /// is what a visibility gate reads, so a peer that kept them would still
942    /// answer as a principal it no longer names — and the revision, profile and
943    /// session id survive, because those are the negotiation a persisted
944    /// session exists to carry, and losing them would answer the next request
945    /// against the wrong feature set.
946    #[test]
947    fn without_auth_keeps_the_session_and_drops_the_principal() -> Result<(), TestError> {
948        let exchange = Arc::new(CountingExchange::answering(r#"{"action":"accept"}"#)?);
949        let sink = Arc::new(RecordingSink::default());
950        let held = ScopeSet::empty().with(Scope::Read).with(Scope::Write);
951        let profile = crate::domain::feature::resolve(crate::domain::feature::Negotiation::bare(
952            ProtocolVersion::V2025_06_18,
953            TransportKind::Stdio,
954        ));
955
956        let holding = peer(ProtocolVersion::V2025_06_18, profile, &exchange, &sink)
957            .with_auth(AuthContext::new("alice", held));
958        assert!(
959            holding.auth().holds(held),
960            "the premise: alice holds what she was given"
961        );
962
963        let stored = holding.without_auth();
964        assert_eq!(stored.auth(), &AuthContext::anonymous());
965        assert!(!stored.auth().holds(held));
966        assert_eq!(stored.negotiated(), ProtocolVersion::V2025_06_18);
967        assert_eq!(stored.profile(), profile);
968        assert_eq!(stored.session(), SessionId::new(7));
969        assert_eq!(stored.transport(), TransportKind::Stdio);
970        Ok(())
971    }
972
973    /// The scenario the task file names: elicitation on a session that never
974    /// negotiated it.
975    ///
976    /// Three claims, and the second is the one that matters. The error has to
977    /// name the feature, or a handler cannot fall back. The refusal has to
978    /// write nothing, or the peer has already seen a request the protocol does
979    /// not permit at this revision. And the exchange must not have been
980    /// called, because "the gate refused" and "the gate ran after the round
981    /// trip and threw the answer away" produce the same error and opposite
982    /// behaviour.
983    #[tokio::test]
984    async fn elicitation_on_an_old_session_is_refused_and_writes_nothing() -> Result<(), TestError>
985    {
986        let exchange = Arc::new(CountingExchange::answering(r#"{"action":"accept"}"#)?);
987        let sink = Arc::new(RecordingSink::default());
988
989        // 2025-03-26 has no elicitation. The profile is built from the real
990        // resolver rather than hand-listed, so a change to the matrix moves
991        // this test instead of quietly invalidating its premise.
992        let profile = crate::domain::feature::resolve(crate::domain::feature::Negotiation::bare(
993            ProtocolVersion::V2025_03_26,
994            crate::domain::TransportKind::Stdio,
995        ));
996        assert!(
997            !profile.allows(Feature::Elicitation),
998            "the premise of this test is that 2025-03-26 has no elicitation"
999        );
1000
1001        let peer = peer(ProtocolVersion::V2025_03_26, profile, &exchange, &sink);
1002        let ctx = RequestContext::for_request(
1003            &peer,
1004            RequestId::number(1),
1005            CancellationToken::new(),
1006            None,
1007        );
1008        let params = JsonObject::new(r#"{"message":"name?"}"#)?;
1009
1010        let refusal = match ctx.elicit(&params).await {
1011            Ok(answer) => {
1012                return Err(Failure::from(format!(
1013                    "elicit unexpectedly returned {answer}"
1014                )));
1015            }
1016            Err(refusal) => refusal,
1017        };
1018
1019        assert_eq!(
1020            refusal,
1021            GatedError::FeatureUnavailable {
1022                feature: Feature::Elicitation,
1023                negotiated: ProtocolVersion::V2025_03_26,
1024            }
1025        );
1026        assert!(refusal.wrote_nothing());
1027        assert_eq!(refusal.feature(), Some(Feature::Elicitation));
1028        assert!(
1029            sink.written().is_empty(),
1030            "the gate must run before the sink is touched"
1031        );
1032        assert!(
1033            exchange
1034                .calls
1035                .lock()
1036                .unwrap_or_else(std::sync::PoisonError::into_inner)
1037                .is_empty(),
1038            "the gate must run before the exchange is reached"
1039        );
1040        Ok(())
1041    }
1042
1043    /// The same gate, permitted, has to actually reach the peer — otherwise a
1044    /// test suite full of refusals would pass against an implementation that
1045    /// never sends anything.
1046    #[tokio::test]
1047    async fn a_negotiated_gate_reaches_the_peer() -> Result<(), TestError> {
1048        let exchange = Arc::new(CountingExchange::answering(r#"{"action":"accept"}"#)?);
1049        let sink = Arc::new(RecordingSink::default());
1050        let peer = peer(
1051            ProtocolVersion::V2025_06_18,
1052            FeatureProfile::empty().with(Feature::Elicitation),
1053            &exchange,
1054            &sink,
1055        );
1056        let ctx = RequestContext::for_request(
1057            &peer,
1058            RequestId::number(1),
1059            CancellationToken::new(),
1060            None,
1061        );
1062        let params = JsonObject::new(r#"{"message":"name?"}"#)?;
1063
1064        let answer = match ctx.elicit(&params).await {
1065            Ok(answer) => answer,
1066            Err(error) => return Err(Failure::from(error)),
1067        };
1068
1069        assert_eq!(answer.as_str(), r#"{"action":"accept"}"#);
1070        assert_eq!(
1071            exchange
1072                .calls
1073                .lock()
1074                .unwrap_or_else(std::sync::PoisonError::into_inner)
1075                .clone(),
1076            vec!["elicitation/create".to_string()]
1077        );
1078        Ok(())
1079    }
1080
1081    /// Every gated call is refused the same way, and names itself.
1082    ///
1083    /// Table-driven over the three, because a gate added later and wired to
1084    /// the wrong feature is exactly the bug a single hand-written test for
1085    /// one of them would miss.
1086    #[tokio::test]
1087    async fn every_gated_call_refuses_when_its_feature_is_absent() -> Result<(), TestError> {
1088        let cases: [(Feature, &str); 3] = [
1089            (Feature::Elicitation, "elicitation/create"),
1090            (Feature::Sampling, "sampling/createMessage"),
1091            (Feature::Tasks, "tasks/create"),
1092        ];
1093
1094        for (feature, method) in cases {
1095            let exchange = Arc::new(CountingExchange::answering("{}")?);
1096            let sink = Arc::new(RecordingSink::default());
1097            let peer = peer(
1098                ProtocolVersion::V2024_11_05,
1099                FeatureProfile::empty(),
1100                &exchange,
1101                &sink,
1102            );
1103            let ctx = RequestContext::for_request(
1104                &peer,
1105                RequestId::number(1),
1106                CancellationToken::new(),
1107                None,
1108            );
1109            let params = JsonObject::new("{}")?;
1110
1111            let outcome = match feature {
1112                Feature::Elicitation => ctx.elicit(&params).await,
1113                Feature::Sampling => ctx.sample(&params).await,
1114                Feature::Tasks => ctx.create_task(&params).await,
1115                _ => {
1116                    return Err(Failure::from(
1117                        "the table named a feature with no gated call",
1118                    ));
1119                }
1120            };
1121
1122            let refusal = match outcome {
1123                Ok(answer) => {
1124                    return Err(Failure::from(format!(
1125                        "{method} unexpectedly returned {answer}"
1126                    )));
1127                }
1128                Err(refusal) => refusal,
1129            };
1130
1131            assert_eq!(
1132                refusal.feature(),
1133                Some(feature),
1134                "{method} refused for the wrong feature"
1135            );
1136            assert!(sink.written().is_empty(), "{method} wrote before refusing");
1137        }
1138        Ok(())
1139    }
1140
1141    /// Progress is refused for a different reason than the feature gates, and
1142    /// the reason matters: the fix is the client's token, not the profile.
1143    #[tokio::test]
1144    async fn progress_without_a_token_refuses_without_touching_the_sink() -> Result<(), TestError> {
1145        let exchange = Arc::new(CountingExchange::answering("{}")?);
1146        let sink = Arc::new(RecordingSink::default());
1147        let peer = peer(
1148            ProtocolVersion::V2025_06_18,
1149            FeatureProfile::all(),
1150            &exchange,
1151            &sink,
1152        );
1153        let ctx = RequestContext::for_request(
1154            &peer,
1155            RequestId::number(1),
1156            CancellationToken::new(),
1157            None,
1158        );
1159        let params = JsonObject::new(r#"{"progress":0.5}"#)?;
1160
1161        let refusal = match ctx.report_progress(&params).await {
1162            Ok(()) => {
1163                return Err(Failure::from("report_progress unexpectedly succeeded"));
1164            }
1165            Err(refusal) => refusal,
1166        };
1167
1168        assert_eq!(refusal, GatedError::NoProgressToken);
1169        assert!(refusal.wrote_nothing());
1170        assert!(refusal.feature().is_none());
1171        assert!(sink.written().is_empty());
1172        Ok(())
1173    }
1174
1175    /// A progress report that does have a token reaches the sink, with the
1176    /// token spliced in rather than the caller's guess at it.
1177    #[tokio::test]
1178    async fn a_progress_report_carries_the_real_token() -> Result<(), TestError> {
1179        let exchange = Arc::new(CountingExchange::answering("{}")?);
1180        let sink = Arc::new(RecordingSink::default());
1181        let peer = peer(
1182            ProtocolVersion::V2025_06_18,
1183            FeatureProfile::all(),
1184            &exchange,
1185            &sink,
1186        );
1187        let ctx = RequestContext::for_request(
1188            &peer,
1189            RequestId::number(1),
1190            CancellationToken::new(),
1191            Some(ProgressToken::text("real-token")),
1192        );
1193        // A caller that supplies its own progressToken must not be able to
1194        // redirect the report onto a stream the peer did not open.
1195        let params = JsonObject::new(r#"{"progressToken":"forged","progress":0.5}"#)?;
1196
1197        ctx.report_progress(&params).await?;
1198
1199        let frames = sink.written();
1200        let frame = frames.first().ok_or("the sink received nothing")?;
1201        let parsed: serde_json::Value =
1202            serde_json::from_str(frame).map_err(|e| format!("the frame is not JSON: {e}"))?;
1203
1204        assert_eq!(at(&parsed, &["method"])?, "notifications/progress");
1205        assert_eq!(at(&parsed, &["params", "progressToken"])?, "real-token");
1206        assert_eq!(at(&parsed, &["params", "progress"])?, 0.5);
1207        assert!(
1208            at(&parsed, &["params", "forged"]).is_err(),
1209            "the caller's extra members should not appear"
1210        );
1211        Ok(())
1212    }
1213
1214    /// A token containing a quote must not produce a frame that is not JSON.
1215    ///
1216    /// This is the T09 `Debug`-escaping defect, reached from the other
1217    /// direction: the frame is assembled by string concatenation in the
1218    /// obvious implementation, and a progress token is caller-supplied.
1219    #[tokio::test]
1220    async fn a_quote_in_the_token_still_produces_valid_json() -> Result<(), TestError> {
1221        let exchange = Arc::new(CountingExchange::answering("{}")?);
1222        let sink = Arc::new(RecordingSink::default());
1223        let peer = peer(
1224            ProtocolVersion::V2025_06_18,
1225            FeatureProfile::all(),
1226            &exchange,
1227            &sink,
1228        );
1229        let ctx = RequestContext::for_request(
1230            &peer,
1231            RequestId::number(1),
1232            CancellationToken::new(),
1233            Some(ProgressToken::text(r#"a"b\c"#)),
1234        );
1235
1236        ctx.report_progress(&JsonObject::new("{}")?).await?;
1237
1238        let frames = sink.written();
1239        let frame = frames.first().ok_or("the sink received nothing")?;
1240        let parsed: serde_json::Value =
1241            serde_json::from_str(frame).map_err(|e| format!("the frame is not JSON: {e}"))?;
1242        assert_eq!(at(&parsed, &["params", "progressToken"])?, r#"a"b\c"#);
1243        Ok(())
1244    }
1245
1246    /// The debug rendering of a context must not carry a usable request id.
1247    ///
1248    /// The redaction rule names session ids and tokens; a request id is the
1249    /// same class of value, and the derived `Debug` would print it whole
1250    /// because `RequestId` has a `Debug` that a log line will call.
1251    #[test]
1252    fn a_context_does_not_print_its_request_id_in_full() -> Result<(), TestError> {
1253        let exchange = Arc::new(CountingExchange::answering("{}")?);
1254        let sink = Arc::new(RecordingSink::default());
1255        let peer = peer(
1256            ProtocolVersion::V2025_06_18,
1257            FeatureProfile::all(),
1258            &exchange,
1259            &sink,
1260        );
1261        let ctx = RequestContext::for_request(
1262            &peer,
1263            RequestId::text("secret-request-id"),
1264            CancellationToken::new(),
1265            None,
1266        );
1267
1268        let rendered = format!("{ctx:?}");
1269        assert!(
1270            !rendered.contains("secret-request-id"),
1271            "the debug form leaked the request id: {rendered}"
1272        );
1273        assert!(rendered.contains("RequestContext"));
1274        Ok(())
1275    }
1276
1277    // -----------------------------------------------------------------
1278    // Issue #12: the typed state extension lets `#[tool]` handlers
1279    // reach the state registered on the server without a process
1280    // global and without the macro knowing about a receiver.
1281    // -----------------------------------------------------------------
1282
1283    /// A `for_request` context has no state, so `state::<T>()` returns
1284    /// `None`. This is the path a test that does not wire state takes;
1285    /// changing it would break the existing `for_request` callers that
1286    /// rely on the no-state default.
1287    #[test]
1288    fn a_default_context_has_no_state() -> Result<(), TestError> {
1289        let exchange = Arc::new(CountingExchange::answering("{}")?);
1290        let sink = Arc::new(RecordingSink::default());
1291        let peer = peer(
1292            ProtocolVersion::V2025_06_18,
1293            FeatureProfile::all(),
1294            &exchange,
1295            &sink,
1296        );
1297        let ctx = RequestContext::for_request(
1298            &peer,
1299            RequestId::number(1),
1300            CancellationToken::new(),
1301            None,
1302        );
1303        if ctx.state::<TestConfig>().is_some() {
1304            return Err(Failure::Message(
1305                "a default context returned a state value".into(),
1306            ));
1307        }
1308        Ok(())
1309    }
1310
1311    /// A `for_request_with_state` context reaches a value put in the
1312    /// state map. The point of the test: the lookup is type-keyed, so
1313    /// `state::<TestConfig>()` for an unrelated type returns `None`
1314    /// even when the map has entries.
1315    #[test]
1316    fn a_state_typed_lookup_finds_its_value() -> Result<(), TestError> {
1317        let exchange = Arc::new(CountingExchange::answering("{}")?);
1318        let sink = Arc::new(RecordingSink::default());
1319        let peer = peer(
1320            ProtocolVersion::V2025_06_18,
1321            FeatureProfile::all(),
1322            &exchange,
1323            &sink,
1324        );
1325        let mut map: StateMap = HashMap::new();
1326        map.insert(
1327            TypeId::of::<TestConfig>(),
1328            Arc::new(TestConfig { version: 7 }) as Arc<dyn Any + Send + Sync>,
1329        );
1330        let state = Arc::new(map);
1331        let ctx = RequestContext::for_request_with_state(
1332            &peer,
1333            RequestId::number(1),
1334            CancellationToken::new(),
1335            None,
1336            Arc::clone(&state),
1337        );
1338        let found = ctx.state::<TestConfig>();
1339        if found != Some(&TestConfig { version: 7 }) {
1340            return Err(Failure::Message(format!(
1341                "expected Some(&TestConfig {{ version: 7 }}), got {found:?}"
1342            )));
1343        }
1344        // Wrong type, same map: must not be reachable through this lookup.
1345        if ctx.state::<TestOtherConfig>().is_some() {
1346            return Err(Failure::Message(
1347                "an unrelated type was found in the state map".into(),
1348            ));
1349        }
1350        Ok(())
1351    }
1352
1353    /// The type the lookup test searches for. Carries no behavior
1354    /// beyond a `version` field; the test asserts on the field, not
1355    /// the trait surface.
1356    #[derive(Debug, PartialEq, Eq)]
1357    struct TestConfig {
1358        version: u32,
1359    }
1360
1361    /// A second type the lookup test uses to assert the type-keyed
1362    /// miss path. `T` and `OtherT` are stored as their own
1363    /// `TypeId`s, and `state::<OtherT>()` must not return a
1364    /// `T` value.
1365    #[derive(Debug, PartialEq, Eq)]
1366    struct TestOtherConfig {
1367        name: String,
1368    }
1369}