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.17", 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(¶ms);
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(¶ms).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(¶ms).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(¶ms);
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}