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