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