Skip to main content

turbomcp_core/
context.rs

1//! Unified request context for MCP handlers.
2//!
3//! This module provides the canonical [`RequestContext`] carried through every
4//! MCP request. It is the single source of truth across the workspace:
5//! `turbomcp-server`, `turbomcp-protocol`, and `turbomcp-wasm` all re-export
6//! this type. `#[tool]`, `#[resource]`, and `#[prompt]` bodies receive
7//! `&RequestContext`; calling `ctx.sample(...)`, `ctx.elicit_form(...)`,
8//! `ctx.elicit_url(...)`, or `ctx.notify_client(...)` works as long as the
9//! transport populated a bidirectional [`McpSession`].
10//!
11//! # Design
12//!
13//! - `alloc`-only fields are available in `no_std` builds (WASM, embedded).
14//! - Richer runtime fields (`start_time`, `headers`, `cancellation_token`) are
15//!   gated behind `#[cfg(feature = "std")]` and omitted from `no_std` builds.
16//! - The session handle is held as `Arc<dyn McpSession>` so every transport
17//!   can plug in without changing the type.
18
19use alloc::string::{String, ToString};
20use alloc::sync::Arc;
21use alloc::vec::Vec;
22
23use hashbrown::HashMap as HashbrownMap;
24use serde_json::Value;
25
26use crate::auth::Principal;
27use crate::error::{McpError, McpResult};
28use crate::session::McpSession;
29
30#[cfg(feature = "std")]
31use crate::session::Cancellable;
32
33#[cfg(feature = "std")]
34use std::time::Instant;
35
36use turbomcp_types::{ClientCapabilities, CreateMessageRequest, CreateMessageResult, ElicitResult};
37
38/// Transport type identifier.
39///
40/// Indicates which transport received the request. This is useful for:
41/// - Logging and metrics
42/// - Transport-specific behavior (e.g., different timeouts)
43/// - Debugging and tracing
44#[derive(
45    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
46)]
47#[serde(rename_all = "lowercase")]
48#[non_exhaustive]
49pub enum TransportType {
50    /// Standard I/O transport (default for CLI tools)
51    #[default]
52    Stdio,
53    /// HTTP transport (REST or SSE)
54    Http,
55    /// WebSocket transport
56    WebSocket,
57    /// Raw TCP transport
58    Tcp,
59    /// Unix domain socket transport
60    Unix,
61    /// WebAssembly/Worker transport (Cloudflare Workers, etc.)
62    Wasm,
63    /// In-process channel transport (zero-copy, no serialization overhead)
64    Channel,
65    /// Unknown or custom transport
66    Unknown,
67}
68
69impl TransportType {
70    /// Returns true if this is a network-based transport.
71    #[inline]
72    pub fn is_network(&self) -> bool {
73        matches!(self, Self::Http | Self::WebSocket | Self::Tcp)
74    }
75
76    /// Returns true if this is a local transport.
77    #[inline]
78    pub fn is_local(&self) -> bool {
79        matches!(self, Self::Stdio | Self::Unix | Self::Channel)
80    }
81
82    /// Returns the transport name as a string.
83    pub fn as_str(&self) -> &'static str {
84        match self {
85            Self::Stdio => "stdio",
86            Self::Http => "http",
87            Self::WebSocket => "websocket",
88            Self::Tcp => "tcp",
89            Self::Unix => "unix",
90            Self::Wasm => "wasm",
91            Self::Channel => "channel",
92            Self::Unknown => "unknown",
93        }
94    }
95}
96
97impl core::fmt::Display for TransportType {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        write!(f, "{}", self.as_str())
100    }
101}
102
103/// Canonical per-request context.
104///
105/// Carries request identity, transport information, authentication principal,
106/// arbitrary typed metadata, and — when the transport supports bidirectional
107/// communication — an [`McpSession`] handle that enables server-to-client
108/// operations such as sampling and elicitation.
109///
110/// # Thread Safety
111///
112/// `RequestContext` is `Send + Sync` on native targets. On WASM targets the
113/// `Send`/`Sync` bounds are dropped (single-threaded runtime).
114#[derive(Debug, Clone, Default)]
115pub struct RequestContext {
116    /// Unique request identifier (JSON-RPC id as string, or generated UUID).
117    pub request_id: String,
118
119    /// Transport type that received this request.
120    pub transport: TransportType,
121
122    /// Authenticated user identifier, if the request was authenticated.
123    pub user_id: Option<String>,
124
125    /// Session identifier for stateful transports (HTTP + session cookie, WS,
126    /// Streamable HTTP, etc.).
127    pub session_id: Option<String>,
128
129    /// Client application identifier reported by the peer.
130    pub client_id: Option<String>,
131
132    /// Rich typed metadata (headers, trace IDs, custom per-request data).
133    pub metadata: HashbrownMap<String, Value>,
134
135    /// Authenticated principal, if auth is configured and succeeded.
136    pub principal: Option<Principal>,
137
138    /// Bidirectional session handle for server-to-client requests.
139    ///
140    /// Populated by the server dispatcher before routing; `None` on
141    /// unidirectional transports (e.g., stateless HTTP) or when the request
142    /// is being synthesized (tests, examples).
143    pub session: Option<Arc<dyn McpSession>>,
144
145    /// HTTP-layer headers for HTTP/WebSocket transports.
146    ///
147    /// Populated by the transport; `None` for non-HTTP transports. Uses
148    /// `hashbrown::HashMap` so it stays available in `no_std` / WASM builds.
149    pub headers: Option<HashbrownMap<String, String>>,
150
151    /// Wall-clock moment at which the server began processing the request.
152    ///
153    /// Used for `elapsed()` measurements and tracing spans.
154    #[cfg(feature = "std")]
155    pub start_time: Option<Instant>,
156
157    /// Cooperative-cancellation handle.
158    ///
159    /// Tool bodies should check `ctx.is_cancelled()` during long operations
160    /// and abort early. The server wires a `tokio_util::sync::CancellationToken`
161    /// in here (via the `Cancellable` blanket impl in `turbomcp-server`).
162    #[cfg(feature = "std")]
163    pub cancellation_token: Option<Arc<dyn Cancellable>>,
164}
165
166// ====================================================================
167// Constructors
168// ====================================================================
169
170impl RequestContext {
171    /// Create a new request context with a freshly generated UUID and Stdio transport.
172    ///
173    /// For WASM/no_std builds the request ID is empty; call
174    /// [`Self::with_id`] explicitly to set one.
175    pub fn new() -> Self {
176        #[cfg(feature = "std")]
177        {
178            Self {
179                request_id: uuid::Uuid::new_v4().to_string(),
180                ..Default::default()
181            }
182        }
183        #[cfg(not(feature = "std"))]
184        {
185            Self::default()
186        }
187    }
188
189    /// Create a context with the given ID and transport.
190    pub fn with_id_and_transport(request_id: impl Into<String>, transport: TransportType) -> Self {
191        Self {
192            request_id: request_id.into(),
193            transport,
194            ..Default::default()
195        }
196    }
197
198    /// Create a context with an explicit request ID (Stdio transport).
199    pub fn with_id(request_id: impl Into<String>) -> Self {
200        Self {
201            request_id: request_id.into(),
202            ..Default::default()
203        }
204    }
205
206    /// Create a context for STDIO transport with a fresh UUID.
207    #[inline]
208    pub fn stdio() -> Self {
209        Self::new().with_transport(TransportType::Stdio)
210    }
211
212    /// Create a context for HTTP transport with a fresh UUID.
213    #[inline]
214    pub fn http() -> Self {
215        Self::new().with_transport(TransportType::Http)
216    }
217
218    /// Create a context for WebSocket transport with a fresh UUID.
219    #[inline]
220    pub fn websocket() -> Self {
221        Self::new().with_transport(TransportType::WebSocket)
222    }
223
224    /// Create a context for TCP transport with a fresh UUID.
225    #[inline]
226    pub fn tcp() -> Self {
227        Self::new().with_transport(TransportType::Tcp)
228    }
229
230    /// Create a context for Unix domain socket transport with a fresh UUID.
231    #[inline]
232    pub fn unix() -> Self {
233        Self::new().with_transport(TransportType::Unix)
234    }
235
236    /// Create a context for WASM transport with a fresh UUID.
237    #[inline]
238    pub fn wasm() -> Self {
239        Self::new().with_transport(TransportType::Wasm)
240    }
241
242    /// Create a context for in-process channel transport with a fresh UUID.
243    #[inline]
244    pub fn channel() -> Self {
245        Self::new().with_transport(TransportType::Channel)
246    }
247}
248
249// ====================================================================
250// Builders
251// ====================================================================
252
253impl RequestContext {
254    /// Set the request ID.
255    #[must_use]
256    pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
257        self.request_id = id.into();
258        self
259    }
260
261    /// Set the transport type.
262    #[must_use]
263    pub fn with_transport(mut self, transport: TransportType) -> Self {
264        self.transport = transport;
265        self
266    }
267
268    /// Set the authenticated user ID.
269    #[must_use]
270    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
271        self.user_id = Some(user_id.into());
272        self
273    }
274
275    /// Set the session ID.
276    #[must_use]
277    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
278        self.session_id = Some(session_id.into());
279        self
280    }
281
282    /// Set the client ID.
283    #[must_use]
284    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
285        self.client_id = Some(client_id.into());
286        self
287    }
288
289    /// Set the authenticated principal.
290    #[must_use]
291    pub fn with_principal(mut self, principal: Principal) -> Self {
292        self.principal = Some(principal);
293        self
294    }
295
296    /// Attach a metadata key/value pair.
297    ///
298    /// Accepts any value convertible to `serde_json::Value`, so string
299    /// literals, numbers, and structured data all work.
300    #[must_use]
301    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
302        self.metadata.insert(key.into(), value.into());
303        self
304    }
305
306    /// Attach a bidirectional session handle.
307    #[must_use]
308    pub fn with_session(mut self, session: Arc<dyn McpSession>) -> Self {
309        self.session = Some(session);
310        self
311    }
312
313    /// Attach HTTP headers (case-sensitive keys; [`header`] does
314    /// case-insensitive lookup).
315    ///
316    /// [`header`]: Self::header
317    #[must_use]
318    pub fn with_headers(mut self, headers: HashbrownMap<String, String>) -> Self {
319        self.headers = Some(headers);
320        self
321    }
322
323    /// Mark the request start time.
324    #[cfg(feature = "std")]
325    #[must_use]
326    pub fn with_start_time(mut self, start: Instant) -> Self {
327        self.start_time = Some(start);
328        self
329    }
330
331    /// Attach a cancellation handle.
332    #[cfg(feature = "std")]
333    #[must_use]
334    pub fn with_cancellation_token(mut self, token: Arc<dyn Cancellable>) -> Self {
335        self.cancellation_token = Some(token);
336        self
337    }
338}
339
340// ====================================================================
341// Mutable setters (for middleware that doesn't move the context)
342// ====================================================================
343
344impl RequestContext {
345    /// Mutable metadata insert.
346    pub fn insert_metadata(&mut self, key: impl Into<String>, value: impl Into<Value>) {
347        self.metadata.insert(key.into(), value.into());
348    }
349
350    /// Mutable principal setter.
351    pub fn set_principal(&mut self, principal: Principal) {
352        self.principal = Some(principal);
353    }
354
355    /// Clear the authenticated principal.
356    pub fn clear_principal(&mut self) {
357        self.principal = None;
358    }
359
360    /// Mutable session setter.
361    pub fn set_session(&mut self, session: Arc<dyn McpSession>) {
362        self.session = Some(session);
363    }
364}
365
366// ====================================================================
367// Accessors
368// ====================================================================
369
370impl RequestContext {
371    /// Request ID.
372    #[inline]
373    pub fn request_id(&self) -> &str {
374        &self.request_id
375    }
376
377    /// Returns true when a non-empty request ID is set.
378    #[inline]
379    pub fn has_request_id(&self) -> bool {
380        !self.request_id.is_empty()
381    }
382
383    /// Transport type.
384    #[inline]
385    pub fn transport(&self) -> TransportType {
386        self.transport
387    }
388
389    /// Authenticated user ID, if present.
390    #[inline]
391    pub fn user_id(&self) -> Option<&str> {
392        self.user_id.as_deref()
393    }
394
395    /// Session ID, if present.
396    #[inline]
397    pub fn session_id(&self) -> Option<&str> {
398        self.session_id.as_deref()
399    }
400
401    /// Client ID, if present.
402    #[inline]
403    pub fn client_id(&self) -> Option<&str> {
404        self.client_id.as_deref()
405    }
406
407    /// Rich metadata lookup.
408    #[inline]
409    pub fn get_metadata(&self, key: &str) -> Option<&Value> {
410        self.metadata.get(key)
411    }
412
413    /// Rich metadata lookup, downcast to `&str` for string values.
414    pub fn get_metadata_str(&self, key: &str) -> Option<&str> {
415        self.metadata.get(key).and_then(|v| v.as_str())
416    }
417
418    /// Returns true when a metadata key is set.
419    #[inline]
420    pub fn has_metadata(&self, key: &str) -> bool {
421        self.metadata.contains_key(key)
422    }
423
424    /// Authenticated principal, if any.
425    #[inline]
426    pub fn principal(&self) -> Option<&Principal> {
427        self.principal.as_ref()
428    }
429
430    /// Returns true when the request is authenticated.
431    ///
432    /// A request is considered authenticated when it has either a `principal`
433    /// or a `user_id`. Callers with richer auth semantics should read the
434    /// principal directly.
435    pub fn is_authenticated(&self) -> bool {
436        self.principal.is_some() || self.user_id.is_some()
437    }
438
439    /// Authenticated subject (principal subject, falling back to `user_id`).
440    pub fn subject(&self) -> Option<&str> {
441        self.principal
442            .as_ref()
443            .map(|p| p.subject.as_str())
444            .or(self.user_id.as_deref())
445    }
446
447    /// Session handle, if attached.
448    #[inline]
449    pub fn session(&self) -> Option<&Arc<dyn McpSession>> {
450        self.session.as_ref()
451    }
452
453    /// Returns true when a bidirectional session is attached.
454    #[inline]
455    pub fn has_session(&self) -> bool {
456        self.session.is_some()
457    }
458
459    /// All HTTP headers, if the transport captured any.
460    #[inline]
461    pub fn headers(&self) -> Option<&HashbrownMap<String, String>> {
462        self.headers.as_ref()
463    }
464
465    /// Case-insensitive HTTP header lookup.
466    pub fn header(&self, name: &str) -> Option<&str> {
467        let headers = self.headers.as_ref()?;
468        headers
469            .iter()
470            .find(|(k, _)| k.eq_ignore_ascii_case(name))
471            .map(|(_, v)| v.as_str())
472    }
473
474    /// Elapsed time since the request started (if `start_time` was set).
475    #[cfg(feature = "std")]
476    pub fn elapsed(&self) -> Option<core::time::Duration> {
477        self.start_time.map(|t| t.elapsed())
478    }
479
480    /// Returns true when the request has been marked for cancellation.
481    #[cfg(feature = "std")]
482    pub fn is_cancelled(&self) -> bool {
483        self.cancellation_token
484            .as_ref()
485            .is_some_and(|c| c.is_cancelled())
486    }
487
488    /// Authenticated roles, sourced from the principal or from metadata.
489    ///
490    /// Looks at (in order): `principal.roles`, `metadata["auth"].roles[]`.
491    pub fn roles(&self) -> Vec<String> {
492        if let Some(p) = &self.principal
493            && !p.roles.is_empty()
494        {
495            return p.roles.to_vec();
496        }
497
498        self.metadata
499            .get("auth")
500            .and_then(|auth| auth.get("roles"))
501            .and_then(|r| r.as_array())
502            .map(|arr| {
503                arr.iter()
504                    .filter_map(|v| v.as_str().map(ToString::to_string))
505                    .collect()
506            })
507            .unwrap_or_default()
508    }
509
510    /// Returns true when the principal has any of the specified roles.
511    /// An empty `required` list always returns true.
512    pub fn has_any_role<S: AsRef<str>>(&self, required: &[S]) -> bool {
513        if required.is_empty() {
514            return true;
515        }
516        let roles = self.roles();
517        required
518            .iter()
519            .any(|need| roles.iter().any(|have| have == need.as_ref()))
520    }
521}
522
523// ====================================================================
524// Server-to-client operations (require a session)
525// ====================================================================
526
527impl RequestContext {
528    /// Request LLM sampling from the connected client.
529    ///
530    /// Requires a bidirectional session; returns
531    /// [`McpError::capability_not_supported`] on unidirectional transports.
532    pub async fn sample(&self, request: CreateMessageRequest) -> McpResult<CreateMessageResult> {
533        let session = self.require_session("sampling/createMessage")?;
534        self.require_sampling_capability(session, &request).await?;
535        let params = serde_json::to_value(request).map_err(|e| {
536            McpError::invalid_params(alloc::format!("Failed to serialize sampling request: {e}"))
537        })?;
538        let result = session.call("sampling/createMessage", params).await?;
539        serde_json::from_value(result)
540            .map_err(|e| McpError::internal(alloc::format!("Failed to parse sampling result: {e}")))
541    }
542
543    /// Request form-based user input from the client.
544    pub async fn elicit_form(
545        &self,
546        message: impl Into<String>,
547        schema: Value,
548    ) -> McpResult<ElicitResult> {
549        let session = self.require_session("elicitation/create")?;
550        self.require_elicitation_capability(session, "form").await?;
551        let params = serde_json::json!({
552            "mode": "form",
553            "message": message.into(),
554            "requestedSchema": schema,
555        });
556        let result = session.call("elicitation/create", params).await?;
557        serde_json::from_value(result).map_err(|e| {
558            McpError::internal(alloc::format!("Failed to parse elicitation result: {e}"))
559        })
560    }
561
562    /// Request URL-based user action from the client.
563    pub async fn elicit_url(
564        &self,
565        message: impl Into<String>,
566        url: impl Into<String>,
567        elicitation_id: impl Into<String>,
568    ) -> McpResult<ElicitResult> {
569        let session = self.require_session("elicitation/create")?;
570        self.require_elicitation_capability(session, "url").await?;
571        let params = serde_json::json!({
572            "mode": "url",
573            "message": message.into(),
574            "url": url.into(),
575            "elicitationId": elicitation_id.into(),
576        });
577        let result = session.call("elicitation/create", params).await?;
578        serde_json::from_value(result).map_err(|e| {
579            McpError::internal(alloc::format!("Failed to parse elicitation result: {e}"))
580        })
581    }
582
583    /// Send a JSON-RPC notification to the client.
584    pub async fn notify_client(&self, method: impl AsRef<str>, params: Value) -> McpResult<()> {
585        let session = self.require_session(method.as_ref())?;
586        session.notify(method.as_ref(), params).await
587    }
588
589    fn require_session(&self, op: &str) -> McpResult<&Arc<dyn McpSession>> {
590        self.session.as_ref().ok_or_else(|| {
591            McpError::capability_not_supported(alloc::format!(
592                "Bidirectional session required for {op} but transport does not support it"
593            ))
594        })
595    }
596
597    async fn require_sampling_capability(
598        &self,
599        session: &Arc<dyn McpSession>,
600        request: &CreateMessageRequest,
601    ) -> McpResult<()> {
602        let Some(caps) = session.client_capabilities().await? else {
603            return Ok(());
604        };
605
606        let Some(sampling) = caps.sampling.as_ref() else {
607            return Err(McpError::capability_not_supported(
608                "client sampling capability required for sampling/createMessage",
609            ));
610        };
611
612        if (request.tools.is_some() || request.tool_choice.is_some()) && sampling.tools.is_none() {
613            return Err(McpError::capability_not_supported(
614                "client sampling.tools capability required for tool-enabled sampling/createMessage",
615            ));
616        }
617
618        if request.task.is_some() && !client_supports_task_sampling(&caps) {
619            return Err(McpError::capability_not_supported(
620                "client tasks.requests.sampling.createMessage capability required for task-augmented sampling/createMessage",
621            ));
622        }
623
624        Ok(())
625    }
626
627    async fn require_elicitation_capability(
628        &self,
629        session: &Arc<dyn McpSession>,
630        mode: &str,
631    ) -> McpResult<()> {
632        let Some(caps) = session.client_capabilities().await? else {
633            return Ok(());
634        };
635
636        let Some(elicitation) = caps.elicitation.as_ref() else {
637            return Err(McpError::capability_not_supported(
638                "client elicitation capability required for elicitation/create",
639            ));
640        };
641
642        let supported = match mode {
643            "form" => elicitation.supports_form(),
644            "url" => elicitation.supports_url(),
645            _ => false,
646        };
647
648        if supported {
649            Ok(())
650        } else {
651            Err(McpError::capability_not_supported(alloc::format!(
652                "client elicitation.{mode} capability required for elicitation/create"
653            )))
654        }
655    }
656}
657
658fn client_supports_task_sampling(caps: &ClientCapabilities) -> bool {
659    caps.tasks
660        .as_ref()
661        .and_then(|tasks| tasks.requests.as_ref())
662        .and_then(|requests| requests.sampling.as_ref())
663        .and_then(|sampling| sampling.create_message.as_ref())
664        .is_some()
665}
666
667// ====================================================================
668// Tests
669// ====================================================================
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_transport_type_display() {
677        assert_eq!(TransportType::Stdio.to_string(), "stdio");
678        assert_eq!(TransportType::Http.to_string(), "http");
679        assert_eq!(TransportType::WebSocket.to_string(), "websocket");
680        assert_eq!(TransportType::Tcp.to_string(), "tcp");
681        assert_eq!(TransportType::Unix.to_string(), "unix");
682        assert_eq!(TransportType::Wasm.to_string(), "wasm");
683        assert_eq!(TransportType::Channel.to_string(), "channel");
684        assert_eq!(TransportType::Unknown.to_string(), "unknown");
685    }
686
687    #[test]
688    fn test_transport_type_classification() {
689        assert!(TransportType::Http.is_network());
690        assert!(TransportType::WebSocket.is_network());
691        assert!(TransportType::Tcp.is_network());
692        assert!(!TransportType::Stdio.is_network());
693
694        assert!(TransportType::Stdio.is_local());
695        assert!(TransportType::Unix.is_local());
696        assert!(TransportType::Channel.is_local());
697        assert!(!TransportType::Http.is_local());
698    }
699
700    #[test]
701    fn test_request_context_new() {
702        let ctx = RequestContext::with_id_and_transport("test-123", TransportType::Http);
703        assert_eq!(ctx.request_id(), "test-123");
704        assert_eq!(ctx.transport(), TransportType::Http);
705        assert!(ctx.metadata.is_empty());
706        assert!(!ctx.has_session());
707    }
708
709    #[test]
710    fn test_request_context_factory_methods() {
711        assert_eq!(RequestContext::stdio().transport(), TransportType::Stdio);
712        assert_eq!(RequestContext::http().transport(), TransportType::Http);
713        assert_eq!(
714            RequestContext::websocket().transport(),
715            TransportType::WebSocket
716        );
717        assert_eq!(RequestContext::tcp().transport(), TransportType::Tcp);
718        assert_eq!(RequestContext::unix().transport(), TransportType::Unix);
719        assert_eq!(RequestContext::wasm().transport(), TransportType::Wasm);
720        assert_eq!(
721            RequestContext::channel().transport(),
722            TransportType::Channel
723        );
724    }
725
726    #[test]
727    fn test_request_context_metadata() {
728        let ctx = RequestContext::with_id_and_transport("1", TransportType::Http)
729            .with_metadata("key1", "value1")
730            .with_metadata("count", 42);
731
732        assert_eq!(ctx.get_metadata_str("key1"), Some("value1"));
733        assert_eq!(ctx.get_metadata("count"), Some(&serde_json::json!(42)));
734        assert_eq!(ctx.get_metadata("key3"), None);
735
736        assert!(ctx.has_metadata("key1"));
737        assert!(!ctx.has_metadata("key3"));
738    }
739
740    #[test]
741    fn test_request_context_ids() {
742        let ctx = RequestContext::with_id_and_transport("r", TransportType::Http)
743            .with_user_id("u")
744            .with_session_id("s")
745            .with_client_id("c");
746
747        assert_eq!(ctx.user_id(), Some("u"));
748        assert_eq!(ctx.session_id(), Some("s"));
749        assert_eq!(ctx.client_id(), Some("c"));
750        assert!(ctx.is_authenticated());
751    }
752
753    #[test]
754    fn test_request_context_principal() {
755        let ctx = RequestContext::with_id_and_transport("1", TransportType::Http);
756        assert!(!ctx.is_authenticated());
757        assert!(ctx.principal().is_none());
758        assert!(ctx.subject().is_none());
759
760        let principal = Principal::new("user-123")
761            .with_email("user@example.com")
762            .with_role("admin");
763
764        let ctx = ctx.with_principal(principal);
765        assert!(ctx.is_authenticated());
766        assert_eq!(ctx.subject(), Some("user-123"));
767        assert!(ctx.principal().unwrap().has_role("admin"));
768        assert_eq!(ctx.roles(), alloc::vec![String::from("admin")]);
769        assert!(ctx.has_any_role(&["admin"]));
770        assert!(!ctx.has_any_role(&["root"]));
771    }
772
773    #[test]
774    fn test_request_context_default() {
775        let ctx = RequestContext::default();
776        assert!(ctx.request_id.is_empty());
777        assert_eq!(ctx.transport, TransportType::Stdio);
778        assert!(ctx.metadata.is_empty());
779        assert!(!ctx.has_session());
780    }
781
782    #[test]
783    fn test_request_context_headers() {
784        let mut headers: HashbrownMap<String, String> = HashbrownMap::new();
785        headers.insert("User-Agent".into(), "Test/1.0".into());
786        let ctx =
787            RequestContext::with_id_and_transport("1", TransportType::Http).with_headers(headers);
788
789        assert_eq!(ctx.header("user-agent"), Some("Test/1.0"));
790        assert_eq!(ctx.header("USER-AGENT"), Some("Test/1.0"));
791        assert_eq!(ctx.header("missing"), None);
792    }
793
794    #[cfg(feature = "std")]
795    #[tokio::test]
796    async fn test_sampling_without_session_fails() {
797        use turbomcp_types::CreateMessageRequest;
798        let ctx = RequestContext::stdio();
799        let err = ctx
800            .sample(CreateMessageRequest::default())
801            .await
802            .unwrap_err();
803        assert_eq!(err.kind, crate::error::ErrorKind::CapabilityNotSupported);
804    }
805}