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(¶ms).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(¶ms).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(¶ms).await,
1113 Feature::Sampling => ctx.sample(¶ms).await,
1114 Feature::Tasks => ctx.create_task(¶ms).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(¶ms).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(¶ms).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}