Skip to main content

tower_mcp/
stateless.rs

1//! Stateless MCP support: SEP-1442 opt-in and automatic 2026-07-28 dispatch
2//!
3//! This module contains types and helpers for stateless MCP operation. There are
4//! two distinct paths; understanding which one applies to your deployment is
5//! important.
6//!
7//! ## Two stateless paths
8//!
9//! ### Automatic version-gated path (2026-07-28)
10//!
11//! When the `protocol-2026-07-28` feature is compiled in, JSON-RPC transports
12//! automatically dispatch requests whose per-request `_meta` selects the exact
13//! 2026-07-28 protocol without an initialize handshake. HTTP additionally
14//! requires a matching `MCP-Protocol-Version` header. Client identity and
15//! capabilities flow through per-request `_meta` (see
16//! [`StatelessRequestMeta`]).
17//!
18//! **This path is always active when the feature is compiled in, regardless of
19//! whether [`StatelessConfig`] was provided to the transport.** Adding
20//! `features = ["protocol-2026-07-28"]` to your `Cargo.toml` is sufficient;
21//! you do not need to call `HttpTransport::stateless()`.
22//!
23//! ### Legacy SEP-1442 opt-in path
24//!
25//! [`StatelessConfig`] and `HttpTransport::stateless()` activate the older
26//! SEP-1442-style opt-in stateless behavior for clients that do not use the
27//! 2026-07-28 protocol. This path controls whether sessions are optional,
28//! whether the protocol version must be present in every request, and whether
29//! `server/discover` is enabled.
30//!
31//! ## Feature flag
32//!
33//! This module is gated behind `protocol-2026-07-28`. The former `stateless`
34//! feature remains as a compatibility alias:
35//!
36//! ```toml
37//! tower-mcp = { version = "0.18", features = ["protocol-2026-07-28"] }
38//! ```
39//!
40//! ## Key properties of stateless mode
41//!
42//! 1. **Per-request protocol version**: Every request carries the protocol version
43//! 2. **Optional discovery**: `server/discover` RPC for capability discovery (SEP-2575)
44//! 3. **No sessions required**: Sessions are optional (2026-07-28) or configurable (SEP-1442)
45//! 4. **Per-request client capabilities**: Client info and capabilities ride on each request
46
47use serde::{Deserialize, Serialize};
48
49use crate::protocol::{ClientCapabilities, Implementation, ProgressToken};
50
51// =============================================================================
52// Per-request _meta (SEP-2575 RequestMetaObject)
53// =============================================================================
54
55/// Per-request metadata carried in JSON-RPC `_meta` per SEP-2575.
56///
57/// In the 2026-07-28 protocol every request is self-contained -- there is no
58/// initialize handshake and no session. The fields previously negotiated at
59/// init time (protocol version, client identity, client capabilities) ride on
60/// each request via the `_meta` object. The MCP-defined keys are reverse-DNS
61/// namespaced under `io.modelcontextprotocol/`.
62///
63/// Wire shape (per the FINAL spec):
64///
65/// ```json
66/// "_meta": {
67///   "progressToken": "...",
68///   "io.modelcontextprotocol/protocolVersion": "2026-07-28",
69///   "io.modelcontextprotocol/clientInfo": { "name": "...", "version": "..." },
70///   "io.modelcontextprotocol/clientCapabilities": { ... },
71///   "io.modelcontextprotocol/logLevel": "info"
72/// }
73/// ```
74///
75/// `protocolVersion` and `clientCapabilities` are REQUIRED per the spec.
76/// `clientInfo` is a SHOULD -- clients are expected to send it on every
77/// request unless specifically configured not to, but a server MUST NOT
78/// reject a request for lacking it. All three are typed as `Option<_>` here
79/// so deserialization stays tolerant of partial/transitional clients;
80/// server-side validation should reject requests that lack the two required
81/// fields.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct StatelessRequestMeta {
85    /// Progress token for receiving progress notifications.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub progress_token: Option<ProgressToken>,
88
89    /// The MCP protocol version this request targets.
90    ///
91    /// For HTTP this MUST match the `MCP-Protocol-Version` header. On stdio
92    /// and custom JSON-RPC bindings, this field independently selects the
93    /// request lifecycle. Spec key:
94    /// `io.modelcontextprotocol/protocolVersion`.
95    #[serde(
96        rename = "io.modelcontextprotocol/protocolVersion",
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub protocol_version: Option<String>,
100
101    /// Identifies the client software making this request. Clients SHOULD
102    /// include this on every request unless specifically configured not to
103    /// (SEP-2575, final -- earlier SEP-2575 drafts had this as REQUIRED; the
104    /// upstream schema demoted it before finalization). Self-reported and
105    /// unverified: servers SHOULD NOT use it for behavior or security
106    /// decisions, only display/logging/debugging.
107    #[serde(
108        rename = "io.modelcontextprotocol/clientInfo",
109        skip_serializing_if = "Option::is_none"
110    )]
111    pub client_info: Option<Implementation>,
112
113    /// Capabilities the client advertises for this specific request.
114    /// REQUIRED per SEP-2575.
115    #[serde(
116        rename = "io.modelcontextprotocol/clientCapabilities",
117        skip_serializing_if = "Option::is_none"
118    )]
119    pub client_capabilities: Option<ClientCapabilities>,
120
121    /// Optional per-request log-level override.
122    #[serde(
123        rename = "io.modelcontextprotocol/logLevel",
124        skip_serializing_if = "Option::is_none"
125    )]
126    pub log_level: Option<LogLevel>,
127}
128
129/// Log levels for stateless per-request log level control.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum LogLevel {
133    /// Diagnostic information useful while debugging.
134    Debug,
135    /// General informational messages.
136    Info,
137    /// Normal but significant events.
138    Notice,
139    /// Conditions that may require attention.
140    Warning,
141    /// Error conditions.
142    Error,
143    /// Critical conditions.
144    Critical,
145    /// Conditions requiring immediate action.
146    Alert,
147    /// The system is unusable.
148    Emergency,
149}
150
151// =============================================================================
152// server/discover RPC
153// =============================================================================
154
155// SEP-1442's `server/discover` shape was promoted to the always-available
156// `protocol` module when SEP-2575 finalized. Re-export from here so existing
157// imports keep working; new code should import from `protocol` directly.
158
159#[doc(inline)]
160pub use crate::protocol::{DiscoverParams, DiscoverResult};
161
162// =============================================================================
163// Error codes -- the SEP-1442 draft assignments were wrong; SEP-2575 FINAL
164// canonicalized them in the spec's draft schema. Re-export the corrected
165// values from the always-available error module.
166// =============================================================================
167
168/// Re-exported for SEP-2575 compatibility. Prefer importing from
169/// [`crate::error::McpErrorCode::UnsupportedProtocolVersion`] directly.
170#[doc(inline)]
171pub use crate::error::UnsupportedProtocolVersionData;
172
173/// Legacy SEP-1442 error code constants.
174///
175/// These were wrong: SEP-1442 (draft) placed `UNSUPPORTED_VERSION` at -32000,
176/// which collides with the established `ConnectionClosed` assignment.
177/// SEP-2575 assigned -32004, which the upstream error-code allocation
178/// (spec PR modelcontextprotocol#2907, 2026-06) renumbered to -32022. Use
179/// [`crate::error::McpErrorCode::UnsupportedProtocolVersion`] or
180/// [`crate::error::JsonRpcError::unsupported_protocol_version`] instead.
181pub mod error_codes {
182    /// Spec-correct unsupported protocol version code (SEP-2575, renumbered
183    /// from -32004 to -32022 by spec PR modelcontextprotocol#2907).
184    pub const UNSUPPORTED_PROTOCOL_VERSION: i32 = -32022;
185
186    /// Wrong assignment from SEP-1442 draft. Kept temporarily for
187    /// back-compat; new code should use [`UNSUPPORTED_PROTOCOL_VERSION`].
188    #[deprecated(
189        since = "0.12.0",
190        note = "SEP-1442 draft assignment was wrong; the current draft schema \
191                uses -32022. Use `UNSUPPORTED_PROTOCOL_VERSION` or \
192                `crate::error::McpErrorCode::UnsupportedProtocolVersion`."
193    )]
194    pub const UNSUPPORTED_VERSION: i32 = -32000;
195
196    /// Invalid or missing required session ID (-32001) per the SEP-1442
197    /// draft.
198    ///
199    /// **Deprecated**: SEP-2567 (FINAL) removes sessions entirely, and
200    /// -32001 is no longer assigned by any current SEP (SEP-2243 briefly
201    /// claimed it for `HeaderMismatch` before the upstream renumbering moved
202    /// that code to -32020). New code should use one of:
203    /// - [`crate::error::McpErrorCode::SessionRequired`] (-32006) for a
204    ///   missing session ID, or
205    /// - [`crate::error::McpErrorCode::SessionNotFound`] (-32005) for an
206    ///   unknown or expired session.
207    #[deprecated(
208        since = "0.11.0",
209        note = "Sessions are removed by SEP-2567. Use \
210                McpErrorCode::SessionRequired (-32006) or SessionNotFound (-32005)."
211    )]
212    pub const INVALID_SESSION: i32 = -32001;
213}
214
215// =============================================================================
216// Stateless mode configuration
217// =============================================================================
218
219/// Configuration for the legacy SEP-1442 stateless opt-in path.
220///
221/// **This configuration applies only to the legacy SEP-1442 opt-in path.**
222/// It does NOT control the automatic version-gated path for 2026-07-28+
223/// clients: when the `stateless` feature is compiled in, any request with
224/// `MCP-Protocol-Version: 2026-07-28` and no `mcp-session-id` is dispatched
225/// statelessly regardless of whether this config is set on the transport.
226///
227/// Use [`HttpTransport::stateless()`](crate::transport::http::HttpTransport::stateless)
228/// to attach this configuration to a transport. Omitting it does not disable
229/// stateless support for 2026-07-28+ clients; it only disables the
230/// additional SEP-1442 opt-in behaviors below.
231#[derive(Debug, Clone)]
232pub struct StatelessConfig {
233    /// Whether to require protocol version in every request.
234    ///
235    /// Default: true (as per SEP-1442)
236    pub require_protocol_version: bool,
237
238    /// Whether sessions are optional.
239    ///
240    /// When true, requests without session IDs are allowed and
241    /// the initialize handshake is not required.
242    ///
243    /// Default: true
244    pub optional_sessions: bool,
245
246    /// Whether to enable the `server/discover` RPC.
247    ///
248    /// Default: true
249    pub enable_discover: bool,
250}
251
252impl Default for StatelessConfig {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258impl StatelessConfig {
259    /// Create a new stateless configuration with SEP-1442 defaults.
260    pub fn new() -> Self {
261        Self {
262            require_protocol_version: true,
263            optional_sessions: true,
264            enable_discover: true,
265        }
266    }
267
268    /// Create a configuration that maintains backward compatibility.
269    ///
270    /// This enables stateless features but doesn't require them,
271    /// allowing gradual migration from stateful to stateless.
272    pub fn backward_compatible() -> Self {
273        Self {
274            require_protocol_version: false,
275            optional_sessions: true,
276            enable_discover: true,
277        }
278    }
279}
280
281// =============================================================================
282// Protocol version validation
283// =============================================================================
284
285/// Validate a protocol version string against supported versions.
286///
287/// Returns `Ok(())` if valid, or a JSON-RPC `UnsupportedProtocolVersion`
288/// error (-32022 per SEP-2575 after the upstream renumbering) with the
289/// spec-shape data:
290///
291/// ```json
292/// { "supported": ["..."], "requested": "..." }
293/// ```
294pub fn validate_protocol_version(
295    version: &str,
296) -> std::result::Result<(), crate::error::JsonRpcError> {
297    use crate::protocol::SUPPORTED_PROTOCOL_VERSIONS;
298
299    if SUPPORTED_PROTOCOL_VERSIONS.contains(&version) {
300        Ok(())
301    } else {
302        Err(crate::error::JsonRpcError::unsupported_protocol_version(
303            version,
304            SUPPORTED_PROTOCOL_VERSIONS.iter().copied(),
305        ))
306    }
307}
308
309// =============================================================================
310// Helper functions
311// =============================================================================
312
313impl StatelessRequestMeta {
314    /// Extract stateless request metadata from JSON-RPC request params.
315    ///
316    /// The metadata is expected in the `_meta` field of the params object.
317    pub fn from_params(params: &serde_json::Value) -> Option<Self> {
318        params
319            .get("_meta")
320            .and_then(|meta| serde_json::from_value(meta.clone()).ok())
321    }
322
323    /// Check if this request includes client capabilities.
324    pub fn has_client_capabilities(&self) -> bool {
325        self.client_capabilities.is_some()
326    }
327
328    /// Check if this request includes client identity.
329    pub fn has_client_info(&self) -> bool {
330        self.client_info.is_some()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use proptest::prelude::*;
338
339    fn arb_json() -> impl Strategy<Value = serde_json::Value> {
340        let leaf = prop_oneof![
341            Just(serde_json::Value::Null),
342            any::<bool>().prop_map(serde_json::Value::Bool),
343            any::<i64>().prop_map(|number| serde_json::json!(number)),
344            prop::collection::vec(any::<char>(), 0..256)
345                .prop_map(|chars| serde_json::Value::String(chars.into_iter().collect())),
346        ];
347        leaf.prop_recursive(6, 128, 10, |inner| {
348            prop_oneof![
349                prop::collection::vec(inner.clone(), 0..10).prop_map(serde_json::Value::Array),
350                prop::collection::hash_map("[a-zA-Z0-9_]{0,24}", inner, 0..10)
351                    .prop_map(|map| serde_json::Value::Object(map.into_iter().collect())),
352            ]
353        })
354    }
355
356    proptest! {
357        #![proptest_config(ProptestConfig::with_cases(512))]
358
359        /// `_meta` extraction sees arbitrary request params and must be a
360        /// total parser: malformed or surprising shapes return `None`.
361        #[test]
362        fn from_params_never_panics(params in arb_json()) {
363            let _ = StatelessRequestMeta::from_params(&params);
364        }
365
366        /// Metadata emitted by the typed model survives the same extraction
367        /// path used for incoming final-protocol requests.
368        #[test]
369        fn from_params_round_trips_typed_meta(
370            version_chars in prop::collection::vec(any::<char>(), 0..256)
371        ) {
372            let version: String = version_chars.into_iter().collect();
373            let expected = StatelessRequestMeta {
374                protocol_version: Some(version.clone()),
375                client_capabilities: Some(ClientCapabilities::default()),
376                log_level: Some(LogLevel::Debug),
377                ..StatelessRequestMeta::default()
378            };
379            let params = serde_json::json!({
380                "_meta": serde_json::to_value(expected).unwrap()
381            });
382            let parsed = StatelessRequestMeta::from_params(&params).unwrap();
383            prop_assert_eq!(parsed.protocol_version.as_deref(), Some(version.as_str()));
384            prop_assert!(parsed.client_capabilities.is_some());
385            prop_assert_eq!(parsed.log_level, Some(LogLevel::Debug));
386        }
387    }
388
389    #[test]
390    fn meta_serializes_with_spec_keys() {
391        use crate::protocol::{ClientCapabilities, Implementation};
392
393        let meta = StatelessRequestMeta {
394            progress_token: None,
395            protocol_version: Some("2026-07-28".to_string()),
396            client_info: Some(Implementation {
397                name: "test-client".into(),
398                version: "1.0.0".into(),
399                title: None,
400                description: None,
401                icons: None,
402                website_url: None,
403                meta: None,
404            }),
405            client_capabilities: Some(ClientCapabilities::default()),
406            log_level: Some(LogLevel::Info),
407        };
408
409        let json: serde_json::Value = serde_json::to_value(&meta).unwrap();
410        // SEP-2575 reverse-DNS namespace `io.modelcontextprotocol/`.
411        assert_eq!(
412            json["io.modelcontextprotocol/protocolVersion"],
413            "2026-07-28"
414        );
415        assert_eq!(
416            json["io.modelcontextprotocol/clientInfo"]["name"],
417            "test-client"
418        );
419        assert!(json["io.modelcontextprotocol/clientCapabilities"].is_object());
420        assert_eq!(json["io.modelcontextprotocol/logLevel"], "info");
421
422        // Confirm the dropped SEP-1442 draft keys are NOT emitted.
423        assert!(
424            json.get("modelcontextprotocol.io/mcpProtocolVersion")
425                .is_none()
426        );
427        assert!(json.get("modelcontextprotocol.io/sessionId").is_none());
428        assert!(json.get("io.modelcontextprotocol/sessionId").is_none());
429        assert!(json.get("io.modelcontextprotocol/roots").is_none());
430    }
431
432    #[test]
433    fn meta_deserializes_from_spec_keys() {
434        let json = r#"{
435            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
436            "io.modelcontextprotocol/clientInfo": {
437                "name": "test-client",
438                "version": "1.0.0"
439            },
440            "io.modelcontextprotocol/clientCapabilities": {},
441            "io.modelcontextprotocol/logLevel": "debug"
442        }"#;
443        let meta: StatelessRequestMeta = serde_json::from_str(json).unwrap();
444        assert_eq!(meta.protocol_version.as_deref(), Some("2026-07-28"));
445        assert_eq!(meta.client_info.as_ref().unwrap().name, "test-client");
446        assert!(meta.has_client_capabilities());
447        assert!(meta.has_client_info());
448        assert_eq!(meta.log_level, Some(LogLevel::Debug));
449    }
450
451    #[test]
452    fn from_params_extracts_meta_object() {
453        let params = serde_json::json!({
454            "_meta": {
455                "io.modelcontextprotocol/protocolVersion": "2026-07-28"
456            },
457            "other": "ignored"
458        });
459        let meta = StatelessRequestMeta::from_params(&params).expect("meta present");
460        assert_eq!(meta.protocol_version.as_deref(), Some("2026-07-28"));
461    }
462
463    #[test]
464    fn test_discover_result_serialization() {
465        use crate::protocol::{Implementation, ResultMeta, ServerCapabilities};
466
467        let result = DiscoverResult {
468            supported_versions: vec!["2025-11-25".to_string(), "2025-03-26".to_string()],
469            capabilities: ServerCapabilities::default(),
470            ttl_ms: None,
471            cache_scope: None,
472            instructions: Some("Test instructions".to_string()),
473            meta: Some(ResultMeta {
474                server_info: Some(Implementation {
475                    name: "test-server".to_string(),
476                    version: "1.0.0".to_string(),
477                    title: None,
478                    description: None,
479                    icons: None,
480                    website_url: None,
481                    meta: None,
482                }),
483            }),
484        };
485
486        let json = serde_json::to_value(&result).unwrap();
487        assert!(json["supportedVersions"].is_array());
488        assert_eq!(
489            json["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
490            "test-server"
491        );
492    }
493
494    #[test]
495    fn test_unsupported_protocol_version_data_shape() {
496        // Per SEP-2575 the wire shape is `supported`, not `supportedVersions`,
497        // and the data also carries `requested`.
498        let data = UnsupportedProtocolVersionData {
499            supported: vec!["2026-07-28".to_string(), "2025-11-25".to_string()],
500            requested: "2027-01-01".to_string(),
501        };
502
503        let json = serde_json::to_value(&data).unwrap();
504        assert_eq!(json["supported"][0], "2026-07-28");
505        assert_eq!(json["requested"], "2027-01-01");
506        assert!(
507            json.get("supportedVersions").is_none(),
508            "spec field name is 'supported', not 'supportedVersions': {json}"
509        );
510    }
511
512    #[test]
513    fn test_config_defaults() {
514        let config = StatelessConfig::new();
515        assert!(config.require_protocol_version);
516        assert!(config.optional_sessions);
517        assert!(config.enable_discover);
518    }
519
520    #[test]
521    fn test_config_backward_compatible() {
522        let config = StatelessConfig::backward_compatible();
523        assert!(!config.require_protocol_version);
524        assert!(config.optional_sessions);
525    }
526
527    #[test]
528    fn test_from_params_no_meta() {
529        let params = serde_json::json!({
530            "name": "test-tool"
531        });
532
533        let meta = StatelessRequestMeta::from_params(&params);
534        assert!(meta.is_none());
535    }
536
537    #[test]
538    fn test_has_client_capabilities() {
539        let meta = StatelessRequestMeta {
540            progress_token: None,
541            protocol_version: None,
542            client_info: None,
543            client_capabilities: Some(ClientCapabilities::default()),
544            log_level: None,
545        };
546        assert!(meta.has_client_capabilities());
547
548        let meta_without = StatelessRequestMeta::default();
549        assert!(!meta_without.has_client_capabilities());
550    }
551
552    #[test]
553    fn legacy_sep1442_draft_keys_are_silently_ignored() {
554        // Old `modelcontextprotocol.io/...` namespace keys from the SEP-1442
555        // draft no longer match the spec. They deserialize as None (silently
556        // dropped) -- existing tower-mcp clients that emit them will not
557        // produce errors but the new fields will appear absent.
558        let json = r#"{
559            "modelcontextprotocol.io/mcpProtocolVersion": "2025-11-25",
560            "modelcontextprotocol.io/sessionId": "old",
561            "modelcontextprotocol.io/roots": []
562        }"#;
563        let meta: StatelessRequestMeta = serde_json::from_str(json).unwrap();
564        assert!(meta.protocol_version.is_none());
565        assert!(meta.client_info.is_none());
566        assert!(meta.client_capabilities.is_none());
567    }
568}
569
570#[cfg(test)]
571mod version_property_tests {
572    use super::validate_protocol_version;
573    use crate::protocol::SUPPORTED_PROTOCOL_VERSIONS;
574    use proptest::prelude::*;
575
576    fn arb_version_text() -> BoxedStrategy<String> {
577        prop_oneof![
578            8 => prop::collection::vec(any::<char>(), 0..512)
579                .prop_map(|chars| chars.into_iter().collect()),
580            1 => Just("\0\r\n\t\u{001b}\u{007f}".repeat(64)),
581            1 => Just("2".repeat(16 * 1024)),
582        ]
583        .boxed()
584    }
585
586    proptest! {
587        #![proptest_config(ProptestConfig::with_cases(512))]
588
589        /// Validating an arbitrary version string never panics.
590        #[test]
591        fn validate_never_panics(v in arb_version_text()) {
592            let _ = validate_protocol_version(&v);
593        }
594
595        /// Every supported version validates.
596        #[test]
597        fn supported_versions_accept(i in 0usize..SUPPORTED_PROTOCOL_VERSIONS.len()) {
598            prop_assert!(validate_protocol_version(SUPPORTED_PROTOCOL_VERSIONS[i]).is_ok());
599        }
600
601        /// Anything not in the supported set is rejected.
602        #[test]
603        fn unsupported_versions_reject(v in arb_version_text()) {
604            prop_assume!(!SUPPORTED_PROTOCOL_VERSIONS.contains(&v.as_str()));
605            prop_assert!(validate_protocol_version(&v).is_err());
606        }
607    }
608}